From 14c5f490b1aa5aa1bdd326eea72b1ac7dea4ec12 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 14 May 2026 19:28:48 +0300 Subject: [PATCH] API - CODEX AGENTS: bearer session auth --- README.md | 12 +++++++ docs/IMPLEMENTATION_PLAN.md | 2 +- src/app.ts | 10 ++++++ src/repositories/agents.ts | 66 +++++++++++++++++++++++++++++++++++++ src/routes/agents.ts | 28 +++++++++++++++- src/security/bearer.ts | 20 +++++++++++ 6 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/security/bearer.ts diff --git a/README.md b/README.md index fadda45..17b3524 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are - Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys. - Internal REST endpoints for agent profile, grant, and token lifecycle. - Opaque agent tokens are generated once and stored only as SHA-256 hashes. +- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls. - MCP and Tasker write execution are documented but not implemented yet. ## Local development @@ -54,4 +55,15 @@ curl -X POST http://127.0.0.1:4100/api/v1/agents \ -d '{"owner_user_id":"local-user","owner_email":"local@example.test","display_name":"Local Codex"}' ``` +Create a token and inspect effective agent session: + +```bash +TOKEN=$(curl -sS -X POST http://127.0.0.1:4100/api/v1/agents//tokens \ + -H 'Content-Type: application/json' \ + -d '{"name":"Local Codex token"}' | jq -r .token) + +curl http://127.0.0.1:4100/api/v1/agent-session \ + -H "Authorization: Bearer $TOKEN" +``` + Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 32c5595..6de6663 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -23,7 +23,7 @@ Exit criteria: ## Phase 1. Agent Gateway skeleton -Status: in progress. Initial service, migrations, persistence endpoints, token hashing, local Postgres compose, and smoke checks are implemented. +Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, local Postgres compose, and smoke checks are implemented. Create standalone service with: diff --git a/src/app.ts b/src/app.ts index 8913cdb..d704271 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js"; import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; +import { UnauthorizedError } from "./security/bearer.js"; export async function buildApp(config: AppConfig): Promise { const pool = createPool(config); @@ -39,6 +40,15 @@ export async function buildApp(config: AppConfig): Promise { return; } + if (error instanceof UnauthorizedError) { + void reply.status(401).send({ + ok: false, + error: "unauthorized", + message: error.message, + }); + return; + } + app.log.error(error); void reply.status(500).send({ ok: false, diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 90efedd..da73944 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -44,6 +44,12 @@ export type AgentTokenRecord = { createdAt: string; }; +export type AgentSessionRecord = { + agent: AgentRecord; + token: AgentTokenRecord; + grants: AgentGrantRecord[]; +}; + type AgentRow = { id: string; owner_user_id: string; @@ -77,6 +83,15 @@ type TokenRow = { created_at: Date; }; +type SessionRow = AgentRow & { + token_id: string; + token_name: string; + token_status: AgentTokenStatus; + token_expires_at: Date | null; + token_last_used_at: Date | null; + token_created_at: Date; +}; + export type CreateAgentInput = { ownerUserId: string; ownerEmail?: string | null; @@ -247,6 +262,57 @@ export class AgentsRepository { return mapToken(result.rows[0]); } + async findActiveSessionByToken(token: string): Promise { + const result = await this.pool.query( + ` + SELECT + agents.id, + agents.owner_user_id, + agents.owner_email, + agents.display_name, + agents.avatar_url, + agents.status, + agents.created_at, + agents.updated_at, + agent_tokens.id AS token_id, + agent_tokens.name AS token_name, + agent_tokens.status AS token_status, + agent_tokens.expires_at AS token_expires_at, + agent_tokens.last_used_at AS token_last_used_at, + agent_tokens.created_at AS token_created_at + FROM agent_tokens + JOIN agents ON agents.id = agent_tokens.agent_id + WHERE agent_tokens.token_hash = $1 + AND agent_tokens.status = 'active' + AND agents.status = 'active' + AND (agent_tokens.expires_at IS NULL OR agent_tokens.expires_at > now()) + `, + [hashAgentToken(token)] + ); + + const row = result.rows[0]; + + if (!row) { + return null; + } + + const updatedToken = await this.pool.query( + ` + UPDATE agent_tokens + SET last_used_at = now() + WHERE id = $1 + RETURNING id, agent_id, name, status, expires_at, last_used_at, created_at + `, + [row.token_id] + ); + + return { + agent: mapAgent(row), + token: mapToken(updatedToken.rows[0]), + grants: await this.listGrants(row.id), + }; + } + async createAuditEvent(agentId: string | null, eventType: string, actorUserId?: string, metadata: Record = {}): Promise { await this.pool.query( ` diff --git a/src/routes/agents.ts b/src/routes/agents.ts index fdd9d53..e50c477 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; import { DatabaseNotConfiguredError } from "../db/pool.js"; import { mcpToolDefinitions } from "../mcp/tools.js"; -import type { AgentGrantRecord, AgentRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; +import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; +import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; import { generateAgentToken } from "../security/tokens.js"; type AgentRouteDeps = { @@ -59,6 +60,21 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute mcp_tools: mcpToolDefinitions, })); + app.get("/api/v1/agent-session", async (request) => { + const repository = requireRepository(deps); + const token = parseBearerToken(request.headers.authorization); + const session = await repository.findActiveSessionByToken(token); + + if (!session) { + throw new UnauthorizedError("Agent token is inactive, expired, or revoked."); + } + + return { + ok: true, + agent_session: serializeAgentSession(session), + }; + }); + app.post("/api/v1/agents", async (request, reply) => { const repository = requireRepository(deps); const body = createAgentBodySchema.parse(request.body); @@ -267,3 +283,13 @@ function serializeToken(token: AgentTokenRecord): Record { created_at: token.createdAt, }; } + +function serializeAgentSession(session: AgentSessionRecord): Record { + return { + agent: serializeAgent(session.agent), + token: serializeToken(session.token), + grants: session.grants.map(serializeGrant), + effective_scopes: [...new Set(session.grants.flatMap((grant) => grant.scopes))], + modes: [...new Set(session.grants.map((grant) => grant.mode))], + }; +} diff --git a/src/security/bearer.ts b/src/security/bearer.ts new file mode 100644 index 0000000..95aaacf --- /dev/null +++ b/src/security/bearer.ts @@ -0,0 +1,20 @@ +export class UnauthorizedError extends Error { + constructor(message = "Missing or invalid bearer token.") { + super(message); + this.name = "UnauthorizedError"; + } +} + +export function parseBearerToken(authorizationHeader: string | undefined): string { + if (!authorizationHeader) { + throw new UnauthorizedError(); + } + + const [scheme, token] = authorizationHeader.split(" "); + + if (scheme?.toLowerCase() !== "bearer" || !token) { + throw new UnauthorizedError(); + } + + return token; +}