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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { ownerOnly } from '../auth/owner.js';
|
import { agentOrOwner } from './middleware/agent_auth.js';
|
||||||
import { errorMiddleware, NotFoundError } from './errors.js';
|
import { errorMiddleware, NotFoundError } from './errors.js';
|
||||||
import { router as spacesRouter } from './routes/spaces.js';
|
import { router as spacesRouter } from './routes/spaces.js';
|
||||||
import { router as projectsRouter, spacesScopedRouter as projectsBySpaceRouter } from './routes/projects.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) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
api.use(ownerOnly);
|
api.use(agentOrOwner);
|
||||||
|
|
||||||
api.use('/spaces', spacesRouter);
|
api.use('/spaces', spacesRouter);
|
||||||
api.use('/spaces/:space_id/projects', projectsBySpaceRouter);
|
api.use('/spaces/:space_id/projects', projectsBySpaceRouter);
|
||||||
|
|||||||
32
lib/api/middleware/agent_auth.js
Normal file
32
lib/api/middleware/agent_auth.js
Normal file
@@ -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); }
|
||||||
|
}
|
||||||
55
tests/api/agent_auth.test.js
Normal file
55
tests/api/agent_auth.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user