From 9c688337b026f64760e927b03c54448aee9eb387 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:20:14 -0700 Subject: [PATCH 1/3] feat(backend): log and count agentRuntimeAuth refusals by reason (TASK-712) A failed agent connect used to leave no trace: every refusal branch returned 401 with no log line and no counter, so nothing server-side could say whether an agent had tried and failed. Every 401 in agentRuntimeAuth now goes through one refuse() that records a reason, the route pattern and method, and a token fingerprint (the first 8 hex of the token's sha256). The response is unchanged. - One structured [agent-auth-refused] line per (reason, fingerprint) per 60 s, with the repeats it suppressed reported on the next line. A global budget of 100 lines per window caps a spray of distinct tokens. Counts are never limited. - The token text never reaches the log or the response; the tests assert it. - GET /api/admin/agent-auth/refusals returns per-reason counts since this process started (per replica, like the rate-limit watermark). Admin-only, behind a rate limiter ahead of auth, same shape as routes/admin/pods.ts. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- .../unit/middleware/agentRuntimeAuth.test.js | 99 ++++++++++++++ .../unit/services/agentAuthRefusals.test.js | 82 ++++++++++++ backend/middleware/agentRuntimeAuth.ts | 28 +++- backend/routes/admin/agentAuthRefusals.ts | 37 ++++++ backend/server.ts | 2 + backend/services/agentAuthRefusals.ts | 121 ++++++++++++++++++ 6 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 backend/__tests__/unit/services/agentAuthRefusals.test.js create mode 100644 backend/routes/admin/agentAuthRefusals.ts create mode 100644 backend/services/agentAuthRefusals.ts diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index f1fbf955b1..67092a3dec 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -256,3 +256,102 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (# expect(next).not.toHaveBeenCalled(); }); }); + +// TASK-712: every refusal branch logs one reason line and counts it, and the +// token never appears in what is logged. +describe('agentRuntimeAuth refusals are logged and counted (TASK-712)', () => { + const { agentAuthRefusalCounts, resetAgentAuthRefusalsForTest } = require('../../../services/agentAuthRefusals'); + const { findOne: mockCredentialFindOne } = require('../../../models/AgentCredential'); + const TOKEN = 'cm_agent_secret-token-value-do-not-log'; + const FINGERPRINT_HASH = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + let warn; + + const refusalLines = () => warn.mock.calls + .filter(([tag]) => tag === '[agent-auth-refused]') + .map(([, json]) => JSON.parse(json)); + const allLoggedText = () => warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + + beforeEach(() => { + resetAgentAuthRefusalsForTest(); + mockSecret.hash.mockReturnValue(FINGERPRINT_HASH); + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockCredentialFindOne.mockResolvedValue(null); + }); + afterEach(() => warn.mockRestore()); + + const agentUser = (overrides = {}) => ({ + _id: 'bot-user-1', + username: 'my-byo-agent', + isBot: true, + botMetadata: { agentName: 'my-byo-agent', instanceId: 'default', displayName: 'My BYO Agent' }, + agentRuntimeTokens: [{ tokenHash: FINGERPRINT_HASH }], + ...overrides, + }); + + test('a missing token is refused, logged as missing_token with no fingerprint, and counted', async () => { + const res = buildRes(); + await agentRuntimeAuth({ headers: {}, header: () => undefined, method: 'GET', baseUrl: '/api/agents/runtime' }, res, jest.fn()); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Missing agent token' }); + expect(refusalLines()).toEqual([expect.objectContaining({ + reason: 'missing_token', route: '/api/agents/runtime', method: 'GET', fingerprint: 'none', + })]); + expect(agentAuthRefusalCounts().counts.missing_token).toBe(1); + }); + + test('an unknown token is refused as invalid_token, with the first 8 hex of its hash as fingerprint', async () => { + mockUserFindOne.mockResolvedValue(null); + mockInstallationFindOne.mockResolvedValue(null); + const res = buildRes(); + await agentRuntimeAuth(buildReq(TOKEN), res, jest.fn()); + expect(res.json).toHaveBeenCalledWith({ message: 'Invalid agent token' }); + expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'invalid_token', fingerprint: 'a1b2c3d4' })]); + expect(agentAuthRefusalCounts().counts.invalid_token).toBe(1); + }); + + test('a User-row token with no active installation is refused as agent_not_installed', async () => { + mockUserFindOne.mockResolvedValue(agentUser()); + mockInstallationFind.mockReturnValue({ lean: async () => [] }); + const res = buildRes(); + await agentRuntimeAuth(buildReq(TOKEN), res, jest.fn()); + expect(res.json).toHaveBeenCalledWith({ message: 'Agent not installed' }); + expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'agent_not_installed', fingerprint: 'a1b2c3d4' })]); + }); + + test('a User-row token past its own expiry is refused as session_expired', async () => { + mockUserFindOne.mockResolvedValue(agentUser({ + agentRuntimeTokens: [{ tokenHash: FINGERPRINT_HASH, expiresAt: new Date('2020-01-01T00:00:00Z') }], + })); + const res = buildRes(); + await agentRuntimeAuth(buildReq(TOKEN), res, jest.fn()); + expect(res.json).toHaveBeenCalledWith({ message: 'Session token expired' }); + expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'session_expired' })]); + }); + + test('a revoked credential is refused with its own reason, token_revoked', async () => { + mockCredentialFindOne.mockResolvedValue({ _id: 'cred-1', kind: 'runtime', status: 'revoked' }); + const res = buildRes(); + await agentRuntimeAuth(buildReq(TOKEN), res, jest.fn()); + expect(res.json).toHaveBeenCalledWith({ message: 'Token revoked' }); + expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'token_revoked', fingerprint: 'a1b2c3d4' })]); + }); + + test('the token text never appears in any logged output, on any branch', async () => { + mockUserFindOne.mockResolvedValue(null); + mockInstallationFindOne.mockResolvedValue(null); + await agentRuntimeAuth(buildReq(TOKEN), buildRes(), jest.fn()); + await agentRuntimeAuth(buildReq('cm_agent_other'), buildRes(), jest.fn()); + expect(allLoggedText()).not.toContain('secret-token-value'); + expect(allLoggedText()).not.toContain(TOKEN); + }); + + test('a repeated refusal from the same client is logged once and counted every time', async () => { + mockUserFindOne.mockResolvedValue(null); + mockInstallationFindOne.mockResolvedValue(null); + for (let i = 0; i < 5; i += 1) { + await agentRuntimeAuth(buildReq(TOKEN), buildRes(), jest.fn()); + } + expect(refusalLines()).toHaveLength(1); + expect(agentAuthRefusalCounts().counts.invalid_token).toBe(5); + }); +}); diff --git a/backend/__tests__/unit/services/agentAuthRefusals.test.js b/backend/__tests__/unit/services/agentAuthRefusals.test.js new file mode 100644 index 0000000000..8861970dda --- /dev/null +++ b/backend/__tests__/unit/services/agentAuthRefusals.test.js @@ -0,0 +1,82 @@ +const { + AGENT_AUTH_REFUSAL_REASONS, agentAuthRefusalCounts, credentialRefusalReason, recordAgentAuthRefusal, + refusalFingerprint, refusalRoute, resetAgentAuthRefusalsForTest, +} = require('../../../services/agentAuthRefusals'); + +const HASH = 'deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb'; + +describe('agentAuthRefusals (TASK-712)', () => { + let warn; + beforeEach(() => { + resetAgentAuthRefusalsForTest(); + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => warn.mockRestore()); + + const lines = () => warn.mock.calls.map(([, json]) => JSON.parse(json)); + + test('maps every fixed credential-gate message to a bounded reason', () => { + expect(credentialRefusalReason('Token revoked')).toBe('token_revoked'); + expect(credentialRefusalReason('Credential owner is being deleted')).toBe('credential_owner_deleting'); + expect(credentialRefusalReason('Session token expired')).toBe('session_expired'); + expect(credentialRefusalReason('Issuing credential revoked')).toBe('issuing_credential_revoked'); + expect(credentialRefusalReason('Machine access revoked')).toBe('machine_access_revoked'); + expect(credentialRefusalReason('Invalid agent credential')).toBe('credential_invalid'); + expect(credentialRefusalReason('something unexpected')).toBe('credential_invalid'); + }); + + test('fingerprint is the first 8 hex of the hash, and none without a token', () => { + expect(refusalFingerprint(HASH)).toBe('deadbeef'); + expect(refusalFingerprint(undefined)).toBe('none'); + expect(refusalFingerprint(null)).toBe('none'); + }); + + test('route is the mount prefix plus the route pattern, never a concrete url', () => { + expect(refusalRoute({ baseUrl: '/api/agents/runtime', route: { path: '/pods/:podId/messages' } })) + .toBe('/api/agents/runtime/pods/:podId/messages'); + expect(refusalRoute({ baseUrl: '/api/agents/runtime' })).toBe('/api/agents/runtime'); + expect(refusalRoute({})).toBe('unknown'); + }); + + test('logs one line with the reason, route, method and fingerprint, and no token', () => { + recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', method: 'POST', tokenHash: HASH, now: 1000 }); + expect(lines()).toEqual([{ + reason: 'invalid_token', route: '/r', method: 'POST', fingerprint: 'deadbeef', repeatsSinceLastLog: 0, + }]); + }); + + test('rate-limits per reason and fingerprint, and reports the repeats on the next line', () => { + const at = (ms) => recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: HASH, now: ms }); + at(0); at(1000); at(59_000); + expect(lines()).toHaveLength(1); + at(61_000); + expect(lines()).toHaveLength(2); + expect(lines()[1].repeatsSinceLastLog).toBe(2); + }); + + test('a different fingerprint or reason is its own line', () => { + recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: HASH, now: 0 }); + recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: 'ffffffff' + HASH.slice(8), now: 0 }); + recordAgentAuthRefusal({ reason: 'agent_not_installed', route: '/r', tokenHash: HASH, now: 0 }); + expect(lines()).toHaveLength(3); + }); + + test('a spray of distinct tokens is capped by a global line budget, and counts keep counting', () => { + for (let i = 0; i < 150; i += 1) { + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/r', tokenHash: i.toString(16).padStart(8, '0') + HASH.slice(8), now: 0, + }); + } + expect(lines()).toHaveLength(100); + expect(agentAuthRefusalCounts().counts.invalid_token).toBe(150); + }); + + test('counts are readable per reason, with every reason present and zeroed', () => { + recordAgentAuthRefusal({ reason: 'missing_token', route: '/r', now: 0 }); + const { since, counts } = agentAuthRefusalCounts(); + expect(new Date(since).toString()).not.toBe('Invalid Date'); + expect(Object.keys(counts).sort()).toEqual([...AGENT_AUTH_REFUSAL_REASONS].sort()); + expect(counts.missing_token).toBe(1); + expect(counts.token_revoked).toBe(0); + }); +}); diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 479dee4697..e3fc8a82e3 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -12,6 +12,9 @@ import { markByoConnectionAttempt } from '../services/onboardingMarks'; import { recordByoEvent, seatRegisteredKey } from '../services/byoTelemetry'; import { isSpawnCredential } from '../services/spawnCredentialService'; import { embeddedRuntimeTokenExpired, runtimeCredentialRefusalReason } from '../services/runtimeCredentialGate'; +import { + credentialRefusalReason, recordAgentAuthRefusal, refusalRoute, type AgentAuthRefusalReason, +} from '../services/agentAuthRefusals'; // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -46,11 +49,24 @@ const extractToken = (req: Request): string | undefined => { return req.header('x-commonly-agent-token'); }; +// Every 401 goes through here, so each refusal is logged and counted with its +// reason (TASK-712). The response is unchanged: same status, same message. +const refuse = ( + req: Request, + res: Response, + reason: AgentAuthRefusalReason, + message: string, + tokenHash?: string, +): Response => { + recordAgentAuthRefusal({ reason, route: refusalRoute(req), method: req.method, tokenHash }); + return res.status(401).json({ message }); +}; + export default async function agentRuntimeAuth(req: Request, res: Response, next: NextFunction): Promise { try { const token = extractToken(req); if (!token || !token.startsWith('cm_agent_')) { - return res.status(401).json({ message: 'Missing agent token' }); + return refuse(req, res, 'missing_token', 'Missing agent token'); } const tokenHash = hash(token); @@ -72,7 +88,9 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next const credential = await AgentCredential.findOne({ tokenHash }); if (credential) { const refusal = await runtimeCredentialRefusalReason(credential); - if (refusal) return res.status(401).json({ message: refusal }); + if (refusal) { + return refuse(req, res, credentialRefusalReason(refusal), refusal, tokenHash); + } try { // Settings reads this ledger row. A successful authenticated request // must be visible to the next credential read, including first use. @@ -95,7 +113,7 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next if (agentUser) { if (embeddedRuntimeTokenExpired(agentUser, tokenHash)) { - return res.status(401).json({ message: 'Session token expired' }); + return refuse(req, res, 'session_expired', 'Session token expired', tokenHash); } const tokenRecord = agentUser.agentRuntimeTokens.find((t) => t.tokenHash === tokenHash); // Pre-update value: a token with no lastUsedAt is authenticating for @@ -127,7 +145,7 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next // Old uninstall paths left User-row tokens behind. Even before a data // cleanup, a token with no active installation must not authenticate. if (installations.length === 0) { - return res.status(401).json({ message: 'Agent not installed' }); + return refuse(req, res, 'agent_not_installed', 'Agent not installed', tokenHash); } const installationPodIds = installations .map((inst) => inst?.podId?.toString()) @@ -167,7 +185,7 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next }); if (!installation) { - return res.status(401).json({ message: 'Invalid agent token' }); + return refuse(req, res, 'invalid_token', 'Invalid agent token', tokenHash); } // Same first-use observation as the User-token path above (#916). diff --git a/backend/routes/admin/agentAuthRefusals.ts b/backend/routes/admin/agentAuthRefusals.ts new file mode 100644 index 0000000000..c693d72623 --- /dev/null +++ b/backend/routes/admin/agentAuthRefusals.ts @@ -0,0 +1,37 @@ +export {}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const express = require('express'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const rateLimit = require('express-rate-limit'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const auth = require('../../middleware/auth'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const adminAuth = require('../../middleware/adminAuth'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { cloudflareIpRateLimitKeyGenerator } = require('../../middleware/ipRateLimit'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { agentAuthRefusalCounts } = require('../../services/agentAuthRefusals'); + +const router = express.Router(); + +// Inlined limiter so the route guard sees a rate limit ahead of auth. Skipped +// under NODE_ENV=test. Admin-gated, so this is defense-in-depth. +const adminAgentAuthRefusalsRateLimit = rateLimit({ + windowMs: 60_000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, + skip: () => process.env.NODE_ENV === 'test', + handler: (_req: any, res: any) => res.status(429).json({ error: 'rate_limited' }), +}); + +// GET /api/admin/agent-auth/refusals +// Per-reason count of agentRuntimeAuth refusals since this backend process +// started. Answers "has this agent tried to connect and failed" at reason +// granularity; the token fingerprint in the log lines is the per-client handle. +router.get('/refusals', adminAgentAuthRefusalsRateLimit, auth, adminAuth, (_req: any, res: any) => { + res.json(agentAuthRefusalCounts()); +}); + +module.exports = router; diff --git a/backend/server.ts b/backend/server.ts index 7fee6af354..8c24c400f1 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -55,6 +55,7 @@ const statsRoutes = require('./routes/stats'); const emailRoutes = require('./routes/email'); const showcaseRoutes = require('./routes/showcase'); const adminPodsRoutes = require('./routes/admin/pods'); +const adminAgentAuthRoutes = require('./routes/admin/agentAuthRefusals'); const agentEnsembleRoutes = require('./routes/agentEnsemble'); const globalIntegrationsRoutes = require('./routes/admin/globalIntegrations'); const agentAutonomyAdminRoutes = require('./routes/admin/agentAutonomy'); @@ -301,6 +302,7 @@ app.use('/api/personas', require('./routes/personas')); // profile). Self-gates via auth middleware + owner/admin check inside the route. app.use('/api/agent-memory', require('./routes/agentMemoryView')); app.use('/api/admin/pods', adminPodsRoutes); // Admin pod ops (showcase toggle) +app.use('/api/admin/agent-auth', adminAgentAuthRoutes); // Agent auth refusal counts (TASK-712) app.use('/api/pods', agentEnsembleRoutes); // Agent Ensemble Pod endpoints // Dev tooling and test routes: opt-in only (config/devRoutes), never NODE_ENV, diff --git a/backend/services/agentAuthRefusals.ts b/backend/services/agentAuthRefusals.ts new file mode 100644 index 0000000000..7fe3ed6e07 --- /dev/null +++ b/backend/services/agentAuthRefusals.ts @@ -0,0 +1,121 @@ +// Refusals from agentRuntimeAuth (TASK-712). Every refusal branch used to return +// 401 with no log line and no counter, so nothing server-side could say whether an +// agent had tried to connect and failed. This module logs one structured line per +// reason and token fingerprint, rate-limited, and keeps a per-reason count that an +// admin can read. +// +// It never carries a token. The fingerprint is the first 8 hex of the token's +// sha256, the same hash the credential lookups already use: a correlation handle, +// not a credential. The route is the mount prefix plus the route pattern, never +// originalUrl, so ids and query strings stay out of the line. + +export const AGENT_AUTH_REFUSAL_REASONS = [ + 'missing_token', + 'invalid_token', + 'agent_not_installed', + 'session_expired', + 'credential_invalid', + 'token_revoked', + 'credential_owner_deleting', + 'issuing_credential_revoked', + 'machine_access_revoked', +] as const; + +export type AgentAuthRefusalReason = typeof AGENT_AUTH_REFUSAL_REASONS[number]; + +// The credential gate answers with fixed strings. Map each to a bounded code so +// the reason is a dimension the log and the count can group on. +const CREDENTIAL_REFUSAL_REASONS: Record = { + 'Invalid agent credential': 'credential_invalid', + 'Token revoked': 'token_revoked', + 'Credential owner is being deleted': 'credential_owner_deleting', + 'Session token expired': 'session_expired', + 'Issuing credential revoked': 'issuing_credential_revoked', + 'Machine access revoked': 'machine_access_revoked', +}; + +export const credentialRefusalReason = (message: string): AgentAuthRefusalReason => ( + CREDENTIAL_REFUSAL_REASONS[message] || 'credential_invalid' +); + +// A stuck client polling every few seconds gets one line per minute per +// (reason, fingerprint); the repeats are counted and reported on the next line. +// A spray of distinct tokens gets one line per fingerprint, so a global budget +// caps the line rate too. Counts are never rate-limited. +const LOG_WINDOW_MS = 60_000; +const LOG_BUDGET_PER_WINDOW = 100; +const MAX_TRACKED_KEYS = 5_000; + +const counts: Record = Object.fromEntries( + AGENT_AUTH_REFUSAL_REASONS.map((reason) => [reason, 0]), +) as Record; +const startedAt = new Date().toISOString(); +const lastLogged = new Map(); +let budgetWindowStart = 0; +let budgetUsed = 0; + +export const refusalFingerprint = (tokenHash?: string | null): string => ( + tokenHash ? tokenHash.slice(0, 8) : 'none' +); + +export const refusalRoute = (req: { baseUrl?: string; route?: { path?: unknown } }): string => { + const pattern = typeof req.route?.path === 'string' ? req.route.path : ''; + const route = `${req.baseUrl || ''}${pattern}`; + return route || 'unknown'; +}; + +export const recordAgentAuthRefusal = ({ + reason, route, method, tokenHash, now = Date.now(), +}: { + reason: AgentAuthRefusalReason; + route: string; + method?: string; + tokenHash?: string | null; + now?: number; +}): void => { + counts[reason] += 1; + const fingerprint = refusalFingerprint(tokenHash); + const key = `${reason}:${fingerprint}`; + + const entry = lastLogged.get(key); + if (entry && now - entry.at < LOG_WINDOW_MS) { + entry.suppressed += 1; + return; + } + if (now - budgetWindowStart >= LOG_WINDOW_MS) { + budgetWindowStart = now; + budgetUsed = 0; + } + if (budgetUsed >= LOG_BUDGET_PER_WINDOW) return; + budgetUsed += 1; + + lastLogged.delete(key); + lastLogged.set(key, { at: now, suppressed: 0 }); + if (lastLogged.size > MAX_TRACKED_KEYS) { + const oldest = lastLogged.keys().next().value; + if (oldest !== undefined) lastLogged.delete(oldest); + } + + console.warn('[agent-auth-refused]', JSON.stringify({ + reason, + route, + method: method || null, + fingerprint, + repeatsSinceLastLog: entry?.suppressed ?? 0, + })); +}; + +// Per-reason counts since this backend process started. Per replica, like the +// rate-limit watermark: a fleet total needs a sum across replicas. +export const agentAuthRefusalCounts = () => ({ + since: startedAt, + counts: { ...counts }, +}); + +// Test seam: clears the rate-limit state and counts so each test starts clean. +export const resetAgentAuthRefusalsForTest = (): void => { + for (const reason of AGENT_AUTH_REFUSAL_REASONS) counts[reason] = 0; + lastLogged.clear(); + budgetWindowStart = 0; + budgetUsed = 0; +}; From 89f8b21ad0e105cdaf65a5eda7a8cf01ee788d75 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:31:07 -0700 Subject: [PATCH 2/3] test(auth): cover every refusal branch's token text and the admin route (TASK-712) The token-text check ran only the invalid_token branch. It now runs each token-bearing refusal (invalid, not installed, expired, revoked) with its own token and checks the full captured log. The admin refusals route had no request-level test. A new suite sends real requests through the router and pins 401 (no auth), 403 (non-admin), and 200 with the per-reason count snapshot. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- .../unit/middleware/agentRuntimeAuth.test.js | 54 +++++++++++---- .../routes/admin.agentAuthRefusals.test.js | 68 +++++++++++++++++++ .../unit/services/agentAuthRefusals.test.js | 24 +++++-- 3 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index 67092a3dec..2c7ffa02a5 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -64,6 +64,8 @@ jest.mock('../../../models/Pod', () => ({ })); const agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default; +const { agentAuthRefusalCounts, resetAgentAuthRefusalsForTest } = require('../../../services/agentAuthRefusals'); +const { findOne: mockCredentialFindOne } = require('../../../models/AgentCredential'); const buildReq = (token) => { const headers = { authorization: `Bearer ${token}` }; @@ -260,8 +262,6 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (# // TASK-712: every refusal branch logs one reason line and counts it, and the // token never appears in what is logged. describe('agentRuntimeAuth refusals are logged and counted (TASK-712)', () => { - const { agentAuthRefusalCounts, resetAgentAuthRefusalsForTest } = require('../../../services/agentAuthRefusals'); - const { findOne: mockCredentialFindOne } = require('../../../models/AgentCredential'); const TOKEN = 'cm_agent_secret-token-value-do-not-log'; const FINGERPRINT_HASH = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; let warn; @@ -290,7 +290,10 @@ describe('agentRuntimeAuth refusals are logged and counted (TASK-712)', () => { test('a missing token is refused, logged as missing_token with no fingerprint, and counted', async () => { const res = buildRes(); - await agentRuntimeAuth({ headers: {}, header: () => undefined, method: 'GET', baseUrl: '/api/agents/runtime' }, res, jest.fn()); + const req = { + headers: {}, header: () => undefined, method: 'GET', baseUrl: '/api/agents/runtime', + }; + await agentRuntimeAuth(req, res, jest.fn()); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ message: 'Missing agent token' }); expect(refusalLines()).toEqual([expect.objectContaining({ @@ -315,7 +318,9 @@ describe('agentRuntimeAuth refusals are logged and counted (TASK-712)', () => { const res = buildRes(); await agentRuntimeAuth(buildReq(TOKEN), res, jest.fn()); expect(res.json).toHaveBeenCalledWith({ message: 'Agent not installed' }); - expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'agent_not_installed', fingerprint: 'a1b2c3d4' })]); + expect(refusalLines()).toEqual([ + expect.objectContaining({ reason: 'agent_not_installed', fingerprint: 'a1b2c3d4' }), + ]); }); test('a User-row token past its own expiry is refused as session_expired', async () => { @@ -336,21 +341,42 @@ describe('agentRuntimeAuth refusals are logged and counted (TASK-712)', () => { expect(refusalLines()).toEqual([expect.objectContaining({ reason: 'token_revoked', fingerprint: 'a1b2c3d4' })]); }); - test('the token text never appears in any logged output, on any branch', async () => { - mockUserFindOne.mockResolvedValue(null); - mockInstallationFindOne.mockResolvedValue(null); - await agentRuntimeAuth(buildReq(TOKEN), buildRes(), jest.fn()); - await agentRuntimeAuth(buildReq('cm_agent_other'), buildRes(), jest.fn()); - expect(allLoggedText()).not.toContain('secret-token-value'); - expect(allLoggedText()).not.toContain(TOKEN); + test('the token text never appears in any logged output, on any token-bearing refusal branch', async () => { + const branches = [ + ['invalid_token', 'cm_agent_invalid-branch-token', () => { + mockUserFindOne.mockResolvedValue(null); + mockInstallationFindOne.mockResolvedValue(null); + }], + ['agent_not_installed', 'cm_agent_not-installed-branch-token', () => { + mockUserFindOne.mockResolvedValue(agentUser()); + mockInstallationFind.mockReturnValue({ lean: async () => [] }); + }], + ['session_expired', 'cm_agent_expired-branch-token', () => { + mockUserFindOne.mockResolvedValue(agentUser({ + agentRuntimeTokens: [{ tokenHash: FINGERPRINT_HASH, expiresAt: new Date('2020-01-01T00:00:00Z') }], + })); + }], + ['token_revoked', 'cm_agent_revoked-branch-token', () => { + mockCredentialFindOne.mockResolvedValue({ _id: 'cred-1', kind: 'runtime', status: 'revoked' }); + }], + ]; + await branches.reduce((previous, [, token, arrange]) => previous.then(() => { + arrange(); + return agentRuntimeAuth(buildReq(token), buildRes(), jest.fn()); + }), Promise.resolve()); + expect(refusalLines().map((line) => line.reason)).toEqual(branches.map(([reason]) => reason)); + branches.forEach(([, token]) => { + expect(allLoggedText()).not.toContain(token); + expect(allLoggedText()).not.toContain(token.slice('cm_agent_'.length)); + }); }); test('a repeated refusal from the same client is logged once and counted every time', async () => { mockUserFindOne.mockResolvedValue(null); mockInstallationFindOne.mockResolvedValue(null); - for (let i = 0; i < 5; i += 1) { - await agentRuntimeAuth(buildReq(TOKEN), buildRes(), jest.fn()); - } + await [1, 2, 3, 4, 5].reduce((previous) => previous.then( + () => agentRuntimeAuth(buildReq(TOKEN), buildRes(), jest.fn()), + ), Promise.resolve()); expect(refusalLines()).toHaveLength(1); expect(agentAuthRefusalCounts().counts.invalid_token).toBe(5); }); diff --git a/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js b/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js new file mode 100644 index 0000000000..3afc32bed0 --- /dev/null +++ b/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js @@ -0,0 +1,68 @@ +// The route's guard order (rate limit, auth, adminAuth) is pinned by the route +// guard test. These cases send real requests through the router so the status +// codes an operator sees are checked, not just the handler's return value. +jest.mock('../../../middleware/auth', () => (req, res, next) => { + if (!req.headers['x-test-user']) return res.status(401).json({ message: 'No token' }); + req.user = { id: req.headers['x-test-user'] }; + return next(); +}); +jest.mock('../../../middleware/adminAuth', () => (req, res, next) => { + if (req.headers['x-test-role'] !== 'admin') return res.status(403).json({ message: 'Admin only' }); + return next(); +}); +jest.mock('../../../middleware/ipRateLimit', () => ({ + cloudflareIpRateLimitKeyGenerator: () => 'test-ip', +})); + +const express = require('express'); +const request = require('supertest'); +const adminAgentAuthRoutes = require('../../../routes/admin/agentAuthRefusals'); +const { + AGENT_AUTH_REFUSAL_REASONS, + recordAgentAuthRefusal, + resetAgentAuthRefusalsForTest, +} = require('../../../services/agentAuthRefusals'); + +const buildApp = () => { + const app = express(); + app.use('/api/admin/agent-auth', adminAgentAuthRoutes); + return app; +}; + +describe('GET /api/admin/agent-auth/refusals (TASK-712)', () => { + beforeEach(() => { + resetAgentAuthRefusalsForTest(); + }); + + test('an unauthenticated request is refused with 401', async () => { + const res = await request(buildApp()).get('/api/admin/agent-auth/refusals'); + expect(res.status).toBe(401); + }); + + test('an authenticated non-admin is refused with 403', async () => { + const res = await request(buildApp()) + .get('/api/admin/agent-auth/refusals') + .set('x-test-user', 'user-1'); + expect(res.status).toBe(403); + }); + + test('an admin gets the per-reason counts since process start', async () => { + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'abcdef0123456789', + }); + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'abcdef0123456789', + }); + + const res = await request(buildApp()) + .get('/api/admin/agent-auth/refusals') + .set('x-test-user', 'admin-1') + .set('x-test-role', 'admin'); + + expect(res.status).toBe(200); + expect(res.body.since).toEqual(expect.any(String)); + expect(Object.keys(res.body.counts).sort()).toEqual([...AGENT_AUTH_REFUSAL_REASONS].sort()); + expect(res.body.counts.invalid_token).toBe(2); + expect(res.body.counts.missing_token).toBe(0); + }); +}); diff --git a/backend/__tests__/unit/services/agentAuthRefusals.test.js b/backend/__tests__/unit/services/agentAuthRefusals.test.js index 8861970dda..ee430cdfaf 100644 --- a/backend/__tests__/unit/services/agentAuthRefusals.test.js +++ b/backend/__tests__/unit/services/agentAuthRefusals.test.js @@ -39,25 +39,35 @@ describe('agentAuthRefusals (TASK-712)', () => { }); test('logs one line with the reason, route, method and fingerprint, and no token', () => { - recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', method: 'POST', tokenHash: HASH, now: 1000 }); + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/r', method: 'POST', tokenHash: HASH, now: 1000, + }); expect(lines()).toEqual([{ reason: 'invalid_token', route: '/r', method: 'POST', fingerprint: 'deadbeef', repeatsSinceLastLog: 0, }]); }); test('rate-limits per reason and fingerprint, and reports the repeats on the next line', () => { - const at = (ms) => recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: HASH, now: ms }); - at(0); at(1000); at(59_000); + const at = (ms) => recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/r', tokenHash: HASH, now: ms, + }); + at(0); at(1000); at(59000); expect(lines()).toHaveLength(1); - at(61_000); + at(61000); expect(lines()).toHaveLength(2); expect(lines()[1].repeatsSinceLastLog).toBe(2); }); test('a different fingerprint or reason is its own line', () => { - recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: HASH, now: 0 }); - recordAgentAuthRefusal({ reason: 'invalid_token', route: '/r', tokenHash: 'ffffffff' + HASH.slice(8), now: 0 }); - recordAgentAuthRefusal({ reason: 'agent_not_installed', route: '/r', tokenHash: HASH, now: 0 }); + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/r', tokenHash: HASH, now: 0, + }); + recordAgentAuthRefusal({ + reason: 'invalid_token', route: '/r', tokenHash: `ffffffff${HASH.slice(8)}`, now: 0, + }); + recordAgentAuthRefusal({ + reason: 'agent_not_installed', route: '/r', tokenHash: HASH, now: 0, + }); expect(lines()).toHaveLength(3); }); From a4dd776f7fd35b25461f24ebdc533f78f5f5353a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:33:04 -0700 Subject: [PATCH 3/3] test(auth): use a non-secret-shaped fixture hash in the refusals route test (TASK-712) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js b/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js index 3afc32bed0..4a60c44cc7 100644 --- a/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js +++ b/backend/__tests__/unit/routes/admin.agentAuthRefusals.test.js @@ -48,10 +48,10 @@ describe('GET /api/admin/agent-auth/refusals (TASK-712)', () => { test('an admin gets the per-reason counts since process start', async () => { recordAgentAuthRefusal({ - reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'abcdef0123456789', + reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'token-hash-fixture', }); recordAgentAuthRefusal({ - reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'abcdef0123456789', + reason: 'invalid_token', route: '/api/agents/runtime/context', method: 'GET', tokenHash: 'token-hash-fixture', }); const res = await request(buildApp())