anyagent-bridge 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/bin/anyagent-bridge.js +127 -0
  5. package/client/index.html +525 -0
  6. package/config.example.json +69 -0
  7. package/docs/INSTALL.md +138 -0
  8. package/docs/ROADMAP.md +168 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/WALKTHROUGH.md +82 -0
  11. package/docs/screenshots/.gitkeep +3 -0
  12. package/docs/screenshots/01-startup-banner.png +0 -0
  13. package/docs/screenshots/02-terminal-view.png +0 -0
  14. package/docs/screenshots/03-agent-running.png +0 -0
  15. package/docs/screenshots/04-mobile.png +0 -0
  16. package/package.json +57 -0
  17. package/server/auth/index.js +20 -0
  18. package/server/auth/manager.js +448 -0
  19. package/server/auth/oauth.js +154 -0
  20. package/server/auth/providers/github.js +59 -0
  21. package/server/auth/providers/google.js +44 -0
  22. package/server/auth/sessions.js +160 -0
  23. package/server/auth/store.js +135 -0
  24. package/server/auth/totp.js +140 -0
  25. package/server/index.js +1779 -0
  26. package/server/safety/audit.js +139 -0
  27. package/server/safety/clientip.js +73 -0
  28. package/server/safety/index.js +17 -0
  29. package/server/safety/manager.js +507 -0
  30. package/server/safety/redact.js +153 -0
  31. package/server/safety/sandbox.js +130 -0
  32. package/server/tunnel/adapters/cloudflare-quick.js +40 -0
  33. package/server/tunnel/adapters/cloudflared-named.js +49 -0
  34. package/server/tunnel/adapters/devtunnel.js +54 -0
  35. package/server/tunnel/adapters/tailscale.js +42 -0
  36. package/server/tunnel/base-adapter.js +185 -0
  37. package/server/tunnel/detect.js +65 -0
  38. package/server/tunnel/index.js +15 -0
  39. package/server/tunnel/manager.js +321 -0
  40. package/server/tunnel/registry.js +31 -0
  41. package/test/stage4-boot.js +98 -0
  42. package/test/stage4-smoke.js +267 -0
@@ -0,0 +1,448 @@
1
+ /**
2
+ * AnyAgent Bridge — AuthManager (Stage 3)
3
+ *
4
+ * The one module server/index.js imports for auth. Sits ON TOP of Stage 1's
5
+ * static access token; never removes it. Orchestrates three concerns:
6
+ *
7
+ * 1. Sessions — signed, expiring, revocable logins (SessionStore).
8
+ * 2. TOTP 2FA — enroll/confirm/verify a second factor for token login.
9
+ * 3. OAuth — Google / GitHub sign-in (OAuthManager) → a session.
10
+ *
11
+ * THE ONE RULE that keeps the model coherent and backward-compatible:
12
+ * The static token is a *direct* credential UNLESS `requireLogin` is set OR a
13
+ * TOTP secret has been confirmed. In either of those cases the token becomes
14
+ * *login-only* — it can mint a session (with the 2FA code when enrolled) but is
15
+ * no longer accepted on its own for protected routes. So when OAuth is off,
16
+ * no TOTP is enrolled, and requireLogin is false, the bridge behaves EXACTLY
17
+ * like Stage 2 (the static token works everywhere).
18
+ *
19
+ * Browser sessions ride in an httpOnly cookie (the WS upgrade and fetches carry
20
+ * it automatically, JS never holds the raw secret). Programmatic clients may use
21
+ * `X-Session-Token` / `Authorization: Bearer` / `?session=` instead.
22
+ */
23
+
24
+ const crypto = require('crypto');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const SessionStore = require('./sessions');
29
+ const { AuthStore } = require('./store');
30
+ const { OAuthManager } = require('./oauth');
31
+ const { generateSecret, verifyTotp, matchCounter, provisioningUri } = require('./totp');
32
+
33
+ const COOKIE_NAME = 'aab_session';
34
+
35
+ /** Resolve (or generate + persist) the HMAC secret used to sign sessions. */
36
+ function resolveSessionSecret(configured, dataDir, logger) {
37
+ if (configured) return String(configured);
38
+ const file = path.join(dataDir, 'auth-secret.json');
39
+ try {
40
+ if (fs.existsSync(file)) {
41
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ if (data && data.secret) return data.secret;
43
+ }
44
+ } catch (e) {
45
+ logger.error(`[Auth] Failed to read session secret: ${e.message}`);
46
+ }
47
+ const secret = crypto.randomBytes(32).toString('hex');
48
+ try {
49
+ fs.writeFileSync(file, JSON.stringify({ secret, createdAt: Date.now() }, null, 2), { mode: 0o600 });
50
+ } catch (e) {
51
+ logger.error(`[Auth] Failed to persist session secret: ${e.message}`);
52
+ }
53
+ return secret;
54
+ }
55
+
56
+ class AuthManager {
57
+ constructor(authConfig, deps = {}) {
58
+ this.config = authConfig || {};
59
+ this.config.oauth = this.config.oauth || {};
60
+ this.config.totp = this.config.totp || {};
61
+ this.logger = deps.logger || console;
62
+ this.staticToken = deps.staticToken;
63
+ this.safeEqual = deps.safeEqual || ((a, b) => a === b);
64
+ this.getClientIP = deps.getClientIP || (() => 'unknown');
65
+ this.rateLimit = deps.rateLimit || { check: () => true, record: () => {} };
66
+
67
+ const dataDir = deps.dataDir;
68
+ this.ttlMs = (this.config.sessionTtlHours || 12) * 60 * 60 * 1000;
69
+
70
+ const secret = resolveSessionSecret(this.config.sessionSecret, dataDir, this.logger);
71
+ this.sessions = new SessionStore({
72
+ secret,
73
+ ttlMs: this.ttlMs,
74
+ filePath: path.join(dataDir, 'auth-sessions.json'),
75
+ logger: this.logger
76
+ });
77
+ this.store = new AuthStore({ filePath: path.join(dataDir, 'auth-users.json'), logger: this.logger });
78
+ this.oauth = new OAuthManager({ config: this.config.oauth, logger: this.logger });
79
+ }
80
+
81
+ // ── Derived policy ───────────────────────────────────────────────────────────
82
+ totpEnforced() { return this.store.totpConfirmed; }
83
+ tokenDirectAllowed() { return !(this.config.requireLogin || this.store.totpConfirmed); }
84
+ oauthEnabled() { return !!this.config.oauth.enabled; }
85
+ isEnhanced() { return this.config.requireLogin || this.store.totpConfirmed || this.oauthEnabled(); }
86
+
87
+ // ── Credential resolution (shared by HTTP middleware and WS) ──────────────────
88
+ parseCookies(header) {
89
+ const out = {};
90
+ if (!header || typeof header !== 'string') return out;
91
+ for (const part of header.split(';')) {
92
+ const idx = part.indexOf('=');
93
+ if (idx < 0) continue;
94
+ const k = part.slice(0, idx).trim();
95
+ const v = part.slice(idx + 1).trim();
96
+ if (k) { try { out[k] = decodeURIComponent(v); } catch (_) { out[k] = v; } }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ _collectCreds(req) {
102
+ const creds = [];
103
+ const h = req.headers || {};
104
+ if (h['x-auth-token']) creds.push(h['x-auth-token']);
105
+ if (h['x-session-token']) creds.push(h['x-session-token']);
106
+ const authz = h['authorization'];
107
+ if (authz && /^Bearer\s+/i.test(authz)) creds.push(authz.replace(/^Bearer\s+/i, ''));
108
+ const q = req.query || {};
109
+ if (q.token) creds.push(q.token);
110
+ if (q.session) creds.push(q.session);
111
+ const cookies = req.cookies || {};
112
+ if (cookies[COOKIE_NAME]) creds.push(cookies[COOKIE_NAME]);
113
+ return creds.filter(Boolean).map(String);
114
+ }
115
+
116
+ /** Return a principal ({type:'session',session} | {type:'token'}) or null. */
117
+ resolvePrincipal(req) {
118
+ const creds = this._collectCreds(req);
119
+ for (const c of creds) {
120
+ const s = this.sessions.verify(c);
121
+ if (s) return { type: 'session', session: s };
122
+ }
123
+ if (this.tokenDirectAllowed() && this.staticToken) {
124
+ for (const c of creds) {
125
+ if (this.safeEqual(c, this.staticToken)) return { type: 'token' };
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** WS upgrade auth — reads ?token / ?session and the Cookie header. */
132
+ verifyWs(req) {
133
+ let url;
134
+ try { url = new URL(req.url, `http://${req.headers.host}`); }
135
+ catch (e) { url = { searchParams: new URLSearchParams() }; }
136
+ const faux = {
137
+ headers: req.headers,
138
+ query: { token: url.searchParams.get('token'), session: url.searchParams.get('session') },
139
+ cookies: this.parseCookies(req.headers && req.headers.cookie)
140
+ };
141
+ return this.resolvePrincipal(faux);
142
+ }
143
+
144
+ _isOperator(principal) {
145
+ if (!principal) return false;
146
+ if (principal.type === 'token') return true;
147
+ return principal.type === 'session' && principal.session.provider === 'token';
148
+ }
149
+
150
+ // ── Cookies ──────────────────────────────────────────────────────────────────
151
+ _isSecure(req) {
152
+ if (req.secure) return true;
153
+ const xf = req.headers && req.headers['x-forwarded-proto'];
154
+ return typeof xf === 'string' && xf.split(',')[0].trim() === 'https';
155
+ }
156
+
157
+ _setSessionCookie(req, res, token) {
158
+ const parts = [`${COOKIE_NAME}=${token}`, 'HttpOnly', 'Path=/', 'SameSite=Lax', `Max-Age=${Math.floor(this.ttlMs / 1000)}`];
159
+ if (this._isSecure(req)) parts.push('Secure');
160
+ res.append('Set-Cookie', parts.join('; '));
161
+ }
162
+
163
+ _clearSessionCookie(req, res) {
164
+ const parts = [`${COOKIE_NAME}=`, 'HttpOnly', 'Path=/', 'SameSite=Lax', 'Max-Age=0'];
165
+ if (this._isSecure(req)) parts.push('Secure');
166
+ res.append('Set-Cookie', parts.join('; '));
167
+ }
168
+
169
+ _callbackUrl(req, providerId) {
170
+ const base = this.config.oauth.callbackBaseUrl;
171
+ if (base) return `${String(base).replace(/\/+$/, '')}/api/auth/oauth/${providerId}/callback`;
172
+ const xfProto = req.headers['x-forwarded-proto'];
173
+ const proto = (typeof xfProto === 'string' && xfProto.split(',')[0].trim()) || req.protocol || 'http';
174
+ const host = req.headers['x-forwarded-host'] || req.headers.host;
175
+ return `${proto}://${host}/api/auth/oauth/${providerId}/callback`;
176
+ }
177
+
178
+ // ── Login flows ──────────────────────────────────────────────────────────────
179
+ _verifyTotpOrRecovery(code) {
180
+ const clean = String(code || '').replace(/\s+/g, '');
181
+ if (!clean) return false;
182
+ if (this.store.totpConfirmed) {
183
+ const counter = matchCounter(this.store.totpSecret, clean);
184
+ if (counter >= 0) {
185
+ // Replay guard: a code at a counter we've already accepted is refused,
186
+ // so a sniffed code cannot be reused within its ±1-step validity window.
187
+ if (counter <= this.store.getTotpLastCounter()) return false;
188
+ this.store.setTotpLastCounter(counter);
189
+ return true;
190
+ }
191
+ }
192
+ return this.store.useRecoveryCode(clean); // one-time codes (longer, non-6-digit)
193
+ }
194
+
195
+ loginWithToken(tokenStr, totpCode, ip) {
196
+ if (!tokenStr || !this.staticToken || !this.safeEqual(tokenStr, this.staticToken)) {
197
+ return { ok: false, reason: 'invalid token' };
198
+ }
199
+ if (this.totpEnforced()) {
200
+ if (!totpCode) return { ok: false, needTotp: true, reason: '2FA code required' };
201
+ if (!this._verifyTotpOrRecovery(totpCode)) return { ok: false, needTotp: true, reason: 'invalid 2FA code' };
202
+ }
203
+ const minted = this.sessions.mint({ sub: 'operator', provider: 'token', name: 'Operator', ip });
204
+ return { ok: true, token: minted.token, session: minted.session };
205
+ }
206
+
207
+ _authorizeIdentity(providerId, key) {
208
+ const pc = this.config.oauth[providerId] || {};
209
+ const listRaw = providerId === 'google' ? pc.allowedEmails : pc.allowedLogins;
210
+ const allow = Array.isArray(listRaw) ? listRaw.map(s => String(s).toLowerCase()) : [];
211
+ if (allow.length > 0) {
212
+ return allow.includes(key) ? { ok: true } : { ok: false, reason: 'not in allowlist' };
213
+ }
214
+ // Empty allowlist: optional first-user-claim (TOFU), else fail closed.
215
+ if (this.config.oauth.claimFirstUser) {
216
+ const claimed = this.store.getClaimed(providerId);
217
+ if (claimed.length === 0) { this.store.claim(providerId, key); return { ok: true, claimed: true }; }
218
+ return claimed.includes(key) ? { ok: true } : { ok: false, reason: 'not the claimed user' };
219
+ }
220
+ return { ok: false, reason: 'no allowlist configured' };
221
+ }
222
+
223
+ async completeOAuth(providerId, code, state, ip) {
224
+ const result = await this.oauth.complete(providerId, code, state);
225
+ if (!result.ok) return result;
226
+ const provider = this.oauth.getProvider(providerId);
227
+ const key = provider.allowKey(result.identity);
228
+ if (!key) return { ok: false, reason: 'identity missing allow-key' };
229
+ const authz = this._authorizeIdentity(providerId, key);
230
+ if (!authz.ok) return authz;
231
+ const minted = this.sessions.mint({
232
+ sub: `${providerId}:${result.identity.sub}`,
233
+ provider: providerId,
234
+ name: result.identity.name,
235
+ email: result.identity.email,
236
+ ip
237
+ });
238
+ return { ok: true, token: minted.token, session: minted.session, identity: result.identity };
239
+ }
240
+
241
+ // ── TOTP enrollment ───────────────────────────────────────────────────────────
242
+ totpStatus() {
243
+ return { confirmed: this.store.totpConfirmed, recoveryRemaining: this.store.recoveryCodesRemaining() };
244
+ }
245
+
246
+ beginTotpEnroll() {
247
+ const secret = generateSecret();
248
+ this.store.setPendingTotp(secret);
249
+ const label = this.config.totp.label || 'operator';
250
+ const issuer = this.config.totp.issuer || 'AnyAgent Bridge';
251
+ return { secret, otpauthUrl: provisioningUri(secret, label, issuer) };
252
+ }
253
+
254
+ confirmTotpEnroll(code) {
255
+ const secret = this.store.getPendingTotp();
256
+ if (!secret) return { ok: false, reason: 'no pending enrollment — call setup first' };
257
+ const counter = matchCounter(secret, String(code || '').replace(/\s+/g, ''));
258
+ if (counter < 0) {
259
+ return { ok: false, reason: 'code does not match — check your device clock' };
260
+ }
261
+ const recovery = Array.from({ length: 8 }, () => crypto.randomBytes(5).toString('hex'));
262
+ this.store.confirmTotp(secret, recovery, counter); // baseline the replay counter
263
+ return { ok: true, recoveryCodes: recovery };
264
+ }
265
+
266
+ disableTotp(code) {
267
+ if (!this.store.totpConfirmed) return { ok: true };
268
+ if (!this._verifyTotpOrRecovery(code)) return { ok: false, reason: 'invalid code' };
269
+ this.store.disableTotp();
270
+ return { ok: true };
271
+ }
272
+
273
+ // ── Public config / status (no secrets) ──────────────────────────────────────
274
+ getPublicConfig() {
275
+ // Minimal disclosure: only which login methods to render. Whether 2FA is
276
+ // enrolled / requireLogin is NOT advertised pre-auth — the login response
277
+ // signals needTotp when a code is required. OAuth providers must be listed
278
+ // so the buttons can render (and are observable via /start regardless).
279
+ return {
280
+ methods: {
281
+ token: true,
282
+ oauth: this.oauthEnabled() ? this.oauth.configuredProviders() : { google: false, github: false }
283
+ }
284
+ };
285
+ }
286
+
287
+ getStatus() {
288
+ return {
289
+ requireLogin: !!this.config.requireLogin,
290
+ tokenDirectAccess: this.tokenDirectAllowed(),
291
+ totp: this.totpStatus(),
292
+ oauth: {
293
+ enabled: this.oauthEnabled(),
294
+ providers: this.oauth.configuredProviders(),
295
+ claimFirstUser: !!this.config.oauth.claimFirstUser
296
+ },
297
+ activeSessions: this.sessions.count()
298
+ };
299
+ }
300
+
301
+ _publicSession(s) {
302
+ return { id: s.id, sub: s.sub, provider: s.provider, name: s.name, email: s.email, exp: s.exp };
303
+ }
304
+
305
+ // ── Route registration ────────────────────────────────────────────────────────
306
+ registerRoutes(app, deps = {}) {
307
+ const requireAuth = deps.requireAuth;
308
+ const ip = (req) => this.getClientIP(req);
309
+
310
+ // Operator gate: only the token holder (or a session minted from token login)
311
+ // may administer 2FA and the global session list. An OAuth-authenticated user
312
+ // is NOT the operator and must not enumerate/revoke other principals' sessions.
313
+ const operatorOnly = (req, res, next) => {
314
+ if (!this._isOperator(req.principal)) {
315
+ return res.status(403).json({ error: 'This action is limited to the token operator' });
316
+ }
317
+ next();
318
+ };
319
+
320
+ // Public: what login methods to render.
321
+ app.get('/api/auth/config', (req, res) => res.json(this.getPublicConfig()));
322
+
323
+ // Who am I (any authenticated principal).
324
+ app.get('/api/auth/me', requireAuth, (req, res) => {
325
+ const p = req.principal;
326
+ if (p.type === 'session') {
327
+ const s = p.session;
328
+ return res.json({
329
+ authenticated: true,
330
+ type: 'session',
331
+ user: { sub: s.sub, provider: s.provider, name: s.name, email: s.email, expiresAt: s.exp, sessionId: s.id }
332
+ });
333
+ }
334
+ res.json({ authenticated: true, type: 'token', user: { provider: 'token', name: 'Operator (token)' } });
335
+ });
336
+
337
+ // Token login (+ 2FA when enrolled) → session cookie.
338
+ app.post('/api/auth/login', (req, res) => {
339
+ const clientIP = ip(req);
340
+ if (!this.rateLimit.check(clientIP)) {
341
+ return res.status(429).json({ ok: false, message: 'Too many login attempts. Try again later.' });
342
+ }
343
+ const body = req.body || {};
344
+ const result = this.loginWithToken(body.token || body.password, body.totp, clientIP);
345
+ if (!result.ok) {
346
+ this.rateLimit.record(clientIP, false);
347
+ return res.status(401).json(result);
348
+ }
349
+ this.rateLimit.record(clientIP, true);
350
+ this._setSessionCookie(req, res, result.token);
351
+ res.json({ ok: true, token: result.token, session: this._publicSession(result.session) });
352
+ });
353
+
354
+ // Backward-compatible endpoint (Stage 1/2 clients). Identical shape when no
355
+ // 2FA is enrolled; requires the 2FA code (and returns a session) once it is.
356
+ app.post('/api/auth/verify-local', (req, res) => {
357
+ const clientIP = ip(req);
358
+ if (!this.rateLimit.check(clientIP)) {
359
+ return res.status(429).json({ success: false, message: 'Too many login attempts. Try again later.' });
360
+ }
361
+ const provided = req.body && (req.body.token || req.body.password);
362
+ if (!provided || !this.staticToken || !this.safeEqual(provided, this.staticToken)) {
363
+ this.rateLimit.record(clientIP, false);
364
+ return res.json({ success: false, message: 'Invalid token' });
365
+ }
366
+ if (this.totpEnforced()) {
367
+ const code = req.body && req.body.totp;
368
+ if (!code || !this._verifyTotpOrRecovery(code)) {
369
+ this.rateLimit.record(clientIP, false);
370
+ return res.status(401).json({ success: false, needTotp: true, message: '2FA code required' });
371
+ }
372
+ const minted = this.sessions.mint({ sub: 'operator', provider: 'token', name: 'Operator', ip: clientIP });
373
+ this.rateLimit.record(clientIP, true);
374
+ this._setSessionCookie(req, res, minted.token);
375
+ return res.json({ success: true, token: minted.token, session: this._publicSession(minted.session) });
376
+ }
377
+ this.rateLimit.record(clientIP, true);
378
+ res.json({ success: true, token: this.staticToken });
379
+ });
380
+
381
+ app.post('/api/auth/logout', requireAuth, (req, res) => {
382
+ if (req.principal.type === 'session') this.sessions.revoke(req.principal.session.id);
383
+ this._clearSessionCookie(req, res);
384
+ res.json({ ok: true });
385
+ });
386
+
387
+ app.get('/api/auth/sessions', requireAuth, operatorOnly, (req, res) => {
388
+ res.json({ sessions: this.sessions.list() });
389
+ });
390
+
391
+ app.delete('/api/auth/sessions/:id', requireAuth, operatorOnly, (req, res) => {
392
+ res.json({ ok: this.sessions.revoke(req.params.id) });
393
+ });
394
+
395
+ // ── OAuth ──
396
+ app.get('/api/auth/oauth/:provider/start', (req, res) => {
397
+ const providerId = req.params.provider;
398
+ if (!this.oauthEnabled()) return res.status(404).json({ error: 'OAuth is disabled' });
399
+ if (!this.oauth.getProvider(providerId)) return res.status(404).json({ error: `Unknown provider '${providerId}'` });
400
+ if (!this.oauth.isConfigured(providerId)) return res.status(400).json({ error: `Provider '${providerId}' is not configured` });
401
+ const url = this.oauth.begin(providerId, this._callbackUrl(req, providerId));
402
+ if (!url) return res.status(400).json({ error: 'Could not start OAuth' });
403
+ if (req.query.json === '1') return res.json({ url });
404
+ res.redirect(url);
405
+ });
406
+
407
+ app.get('/api/auth/oauth/:provider/callback', (req, res) => {
408
+ const providerId = req.params.provider;
409
+ if (req.query.error) {
410
+ return res.redirect('/?auth_error=' + encodeURIComponent(String(req.query.error).slice(0, 120)));
411
+ }
412
+ this.completeOAuth(providerId, req.query.code, req.query.state, ip(req)).then((result) => {
413
+ if (!result.ok) {
414
+ this.logger.warn(`[Auth] OAuth ${providerId} denied: ${result.reason}`);
415
+ return res.redirect('/?auth_error=' + encodeURIComponent((result.reason || 'login failed').slice(0, 120)));
416
+ }
417
+ this.logger.log(`[Auth] OAuth ${providerId} login: ${(result.identity && result.identity.name) || result.session.sub}`);
418
+ this._setSessionCookie(req, res, result.token);
419
+ res.redirect('/');
420
+ }).catch((e) => {
421
+ this.logger.error(`[Auth] OAuth callback error: ${e.message}`);
422
+ res.redirect('/?auth_error=' + encodeURIComponent('login error'));
423
+ });
424
+ });
425
+
426
+ // ── TOTP (operator-only) ──
427
+ app.get('/api/auth/totp/status', requireAuth, operatorOnly, (req, res) => res.json(this.totpStatus()));
428
+
429
+ app.post('/api/auth/totp/setup', requireAuth, operatorOnly, (req, res) => {
430
+ if (this.config.totp.enabled === false) return res.status(403).json({ error: 'TOTP is disabled in config' });
431
+ res.json(this.beginTotpEnroll());
432
+ });
433
+
434
+ app.post('/api/auth/totp/confirm', requireAuth, operatorOnly, (req, res) => {
435
+ const result = this.confirmTotpEnroll((req.body || {}).code);
436
+ if (!result.ok) return res.status(400).json(result);
437
+ res.json(result);
438
+ });
439
+
440
+ app.post('/api/auth/totp/disable', requireAuth, operatorOnly, (req, res) => {
441
+ const result = this.disableTotp((req.body || {}).code);
442
+ if (!result.ok) return res.status(400).json(result);
443
+ res.json(result);
444
+ });
445
+ }
446
+ }
447
+
448
+ module.exports = AuthManager;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * AnyAgent Bridge — OAuthManager (Stage 3)
3
+ *
4
+ * Drives the OAuth 2.0 authorization-code flow for the registered providers
5
+ * (Google, GitHub). Owns the CSRF `state` + PKCE `code_verifier` lifecycle:
6
+ * both are generated at "start", stored server-side keyed by state, and consumed
7
+ * exactly once at "callback" (single-use, TTL-bounded). Network calls go through
8
+ * Node 18+ global fetch — no new npm dependency. Nothing here mutates global
9
+ * server state; it returns plain identity objects to the AuthManager.
10
+ *
11
+ * Adding a provider = drop a file under providers/ and register it below.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const PROVIDERS = {
17
+ google: require('./providers/google'),
18
+ github: require('./providers/github')
19
+ };
20
+
21
+ const STATE_TTL_MS = 10 * 60 * 1000; // an auth round-trip must complete within 10 min
22
+ const MAX_PENDING = 200; // hard ceiling on in-flight states (anti-DoS)
23
+
24
+ function b64url(buf) {
25
+ return buf.toString('base64url');
26
+ }
27
+
28
+ class OAuthManager {
29
+ constructor({ config, logger } = {}) {
30
+ this.config = config || {};
31
+ this.logger = logger || console;
32
+ this.pending = new Map(); // state -> { provider, verifier, redirectUri, createdAt }
33
+ }
34
+
35
+ getProvider(id) {
36
+ return PROVIDERS[id] || null;
37
+ }
38
+
39
+ /** Providers that are both known and fully configured (id + secret present). */
40
+ configuredProviders() {
41
+ const out = {};
42
+ for (const id of Object.keys(PROVIDERS)) {
43
+ const pc = this.config[id] || {};
44
+ out[id] = !!(pc.clientId && pc.clientSecret);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ isConfigured(id) {
50
+ const pc = this.config[id] || {};
51
+ return !!(pc.clientId && pc.clientSecret);
52
+ }
53
+
54
+ _sweep() {
55
+ const now = Date.now();
56
+ for (const [state, p] of this.pending.entries()) {
57
+ if (now - p.createdAt > STATE_TTL_MS) this.pending.delete(state);
58
+ }
59
+ // Hard ceiling: even within the TTL window, never let the map grow without
60
+ // bound (the /start route is unauthenticated). Evict oldest-first; a Map
61
+ // preserves insertion order, so the first keys are the oldest.
62
+ while (this.pending.size >= MAX_PENDING) {
63
+ const oldest = this.pending.keys().next().value;
64
+ if (oldest === undefined) break;
65
+ this.pending.delete(oldest);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Build the provider authorize URL and stash the matching state/verifier.
71
+ * @returns {string|null} the redirect URL, or null if provider unconfigured.
72
+ */
73
+ begin(providerId, redirectUri) {
74
+ const provider = this.getProvider(providerId);
75
+ if (!provider || !this.isConfigured(providerId)) return null;
76
+ this._sweep();
77
+
78
+ const pc = this.config[providerId];
79
+ const state = b64url(crypto.randomBytes(24));
80
+ const params = new URLSearchParams({
81
+ client_id: pc.clientId,
82
+ redirect_uri: redirectUri,
83
+ response_type: 'code',
84
+ scope: provider.scope,
85
+ state
86
+ });
87
+ for (const [k, v] of Object.entries(provider.extraAuthParams || {})) params.set(k, v);
88
+
89
+ let verifier = null;
90
+ if (provider.usePkce) {
91
+ verifier = b64url(crypto.randomBytes(32));
92
+ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
93
+ params.set('code_challenge', challenge);
94
+ params.set('code_challenge_method', 'S256');
95
+ }
96
+
97
+ this.pending.set(state, { provider: providerId, verifier, redirectUri, createdAt: Date.now() });
98
+ return `${provider.authUrl}?${params.toString()}`;
99
+ }
100
+
101
+ /**
102
+ * Complete the flow: validate state, exchange the code, fetch identity.
103
+ * Returns { ok:true, identity } or { ok:false, reason }. Never throws.
104
+ * The state is consumed (single-use) regardless of outcome.
105
+ */
106
+ async complete(providerId, code, state) {
107
+ this._sweep();
108
+ const provider = this.getProvider(providerId);
109
+ if (!provider || !this.isConfigured(providerId)) return { ok: false, reason: 'provider not configured' };
110
+ if (!code || !state) return { ok: false, reason: 'missing code or state' };
111
+
112
+ const entry = this.pending.get(state);
113
+ this.pending.delete(state); // single-use even on failure
114
+ if (!entry) return { ok: false, reason: 'invalid or expired state' };
115
+ if (entry.provider !== providerId) return { ok: false, reason: 'state/provider mismatch' };
116
+ if (Date.now() - entry.createdAt > STATE_TTL_MS) return { ok: false, reason: 'state expired' };
117
+
118
+ const pc = this.config[providerId];
119
+ try {
120
+ const body = new URLSearchParams({
121
+ grant_type: 'authorization_code',
122
+ code,
123
+ redirect_uri: entry.redirectUri,
124
+ client_id: pc.clientId,
125
+ client_secret: pc.clientSecret
126
+ });
127
+ if (provider.usePkce && entry.verifier) body.set('code_verifier', entry.verifier);
128
+
129
+ const tokenRes = await fetch(provider.tokenUrl, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
132
+ body: body.toString()
133
+ });
134
+ if (!tokenRes.ok) {
135
+ const txt = await tokenRes.text().catch(() => '');
136
+ return { ok: false, reason: `token exchange failed (${tokenRes.status})`, detail: txt.slice(0, 200) };
137
+ }
138
+ const tokenJson = await tokenRes.json().catch(() => null);
139
+ const accessToken = tokenJson && tokenJson.access_token;
140
+ if (!accessToken) return { ok: false, reason: 'no access_token in response' };
141
+
142
+ const identity = await provider.fetchIdentity(accessToken);
143
+ const invalid = provider.validate ? provider.validate(identity) : null;
144
+ if (invalid) return { ok: false, reason: invalid };
145
+
146
+ return { ok: true, identity, providerId };
147
+ } catch (e) {
148
+ this.logger.error(`[Auth] OAuth ${providerId} error: ${e.message}`);
149
+ return { ok: false, reason: 'oauth network error' };
150
+ }
151
+ }
152
+ }
153
+
154
+ module.exports = { OAuthManager, PROVIDERS };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * AnyAgent Bridge — GitHub OAuth provider (Stage 3)
3
+ *
4
+ * Standard GitHub OAuth App authorization-code flow. GitHub's classic flow does
5
+ * not support PKCE, so CSRF protection rests on the `state` parameter (always
6
+ * enforced by the manager). The allow-key is the GitHub login (username); the
7
+ * email is fetched for display and may be null if the user keeps it private.
8
+ */
9
+
10
+ module.exports = {
11
+ id: 'github',
12
+ label: 'GitHub',
13
+ scope: 'read:user user:email',
14
+ authUrl: 'https://github.com/login/oauth/authorize',
15
+ tokenUrl: 'https://github.com/login/oauth/access_token',
16
+ usePkce: false,
17
+ extraAuthParams: {},
18
+
19
+ async fetchIdentity(accessToken) {
20
+ const headers = {
21
+ Authorization: `Bearer ${accessToken}`,
22
+ Accept: 'application/vnd.github+json',
23
+ 'User-Agent': 'anyagent-bridge'
24
+ };
25
+ const userRes = await fetch('https://api.github.com/user', { headers });
26
+ if (!userRes.ok) throw new Error(`GitHub user ${userRes.status}`);
27
+ const u = await userRes.json();
28
+
29
+ let email = u.email || null;
30
+ if (!email) {
31
+ try {
32
+ const emailRes = await fetch('https://api.github.com/user/emails', { headers });
33
+ if (emailRes.ok) {
34
+ const emails = await emailRes.json();
35
+ if (Array.isArray(emails)) {
36
+ const primary = emails.find(e => e.primary && e.verified) || emails.find(e => e.verified);
37
+ email = primary ? primary.email : null;
38
+ }
39
+ }
40
+ } catch (e) { /* email is best-effort; login is the identity */ }
41
+ }
42
+
43
+ return {
44
+ sub: String(u.id),
45
+ login: u.login || null,
46
+ name: u.name || u.login || null,
47
+ email
48
+ };
49
+ },
50
+
51
+ allowKey(identity) {
52
+ return identity.login ? String(identity.login).toLowerCase() : null;
53
+ },
54
+
55
+ validate(identity) {
56
+ if (!identity.login) return 'GitHub account has no login';
57
+ return null;
58
+ }
59
+ };