From 7862d22a03e53dd046d7184cc6bfafeaecd7a768 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 31 May 2026 20:59:29 +1000 Subject: [PATCH] feat(api): agent bearer auth middleware Add lib/api/middleware/agent_auth.js: agentOrOwner accepts the owner token (kind=user actor) or a hashed agent token (kind=agent actor carrying capabilities + scopes). /api router now mounts this in place of ownerOnly so agent tokens become first-class. Co-Authored-By: Claude Opus 4.7 --- lib/api/index.js | 4 +-- lib/api/middleware/agent_auth.js | 32 +++++++++++++++++++ tests/api/agent_auth.test.js | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 lib/api/middleware/agent_auth.js create mode 100644 tests/api/agent_auth.test.js diff --git a/lib/api/index.js b/lib/api/index.js index 91dcdfd..f72b3ad 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { ownerOnly } from '../auth/owner.js'; +import { agentOrOwner } from './middleware/agent_auth.js'; import { errorMiddleware, NotFoundError } from './errors.js'; import { router as spacesRouter } from './routes/spaces.js'; import { router as projectsRouter, spacesScopedRouter as projectsBySpaceRouter } from './routes/projects.js'; @@ -15,7 +15,7 @@ import { router as sourceDocsRouter, resourcesScopedRouter as sourceDocsByResour export function mountApi(app) { const api = Router(); - api.use(ownerOnly); + api.use(agentOrOwner); api.use('/spaces', spacesRouter); api.use('/spaces/:space_id/projects', projectsBySpaceRouter); diff --git a/lib/api/middleware/agent_auth.js b/lib/api/middleware/agent_auth.js new file mode 100644 index 0000000..480def6 --- /dev/null +++ b/lib/api/middleware/agent_auth.js @@ -0,0 +1,32 @@ +import * as agents from '../../db/repos/agents.js'; + +export async function agentOrOwner(req, res, next) { + const expectedOwner = process.env.OWNER_TOKEN; + if (!expectedOwner) { + return res.status(500).json({ + error: { code: 'no_owner_token', message: 'OWNER_TOKEN not configured' } + }); + } + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'missing bearer token' } }); + } + if (token === expectedOwner) { + req.actor = { kind: 'user', id: null }; + return next(); + } + try { + const agent = await agents.verifyToken(token); + if (!agent) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } }); + } + req.actor = { + kind: 'agent', + id: agent.id, + capabilities: agent.capabilities || {}, + scopes: agent.scopes || {} + }; + next(); + } catch (e) { next(e); } +} diff --git a/tests/api/agent_auth.test.js b/tests/api/agent_auth.test.js new file mode 100644 index 0000000..e645292 --- /dev/null +++ b/tests/api/agent_auth.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; + +let app, ownerHeaders; +const owner = { kind: 'user', id: null }; + +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); }); + +describe('agent_or_owner bearer auth', () => { + it('missing header → 401', async () => { + const res = await request(app).get('/api/spaces'); + expect(res.status).toBe(401); + }); + + it('wrong token → 401', async () => { + const res = await request(app).get('/api/spaces').set('Authorization', 'Bearer wrong'); + expect(res.status).toBe(401); + }); + + it('owner token → 200', async () => { + const res = await request(app).get('/api/spaces').set(ownerHeaders); + expect(res.status).toBe(200); + }); + + it('valid agent token → 200 and req.actor.kind=agent', async () => { + const agent = await agentsRepo.create({ + slug: `a-${Date.now()}`, + name: 'Test', + kind: 'claude', + model: 'sonnet', + capabilities: { read: 'allow', write: 'suggest' }, + scopes: {} + }, owner); + const { token } = await agentsRepo.createToken(agent.id, 'test'); + const res = await request(app).get('/api/spaces').set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + }); + + it('revoked agent token → 401', async () => { + const agent = await agentsRepo.create({ + slug: `b-${Date.now()}`, + name: 'Revoked', + kind: 'claude', + model: 'sonnet', + capabilities: {}, + scopes: {} + }, owner); + const { token, id: tokenId } = await agentsRepo.createToken(agent.id, 'rev'); + await agentsRepo.revokeToken(tokenId); + const res = await request(app).get('/api/spaces').set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(401); + }); +});