API - CODEX AGENTS: bearer session auth

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 19:28:48 +03:00
parent 112522c423
commit 14c5f490b1
6 changed files with 136 additions and 2 deletions

View File

@ -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/<agent-id>/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.

View File

@ -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:

View File

@ -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<FastifyInstance> {
const pool = createPool(config);
@ -39,6 +40,15 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
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,

View File

@ -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<AgentSessionRecord | null> {
const result = await this.pool.query<SessionRow>(
`
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<TokenRow>(
`
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<string, unknown> = {}): Promise<void> {
await this.pool.query(
`

View File

@ -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<string, unknown> {
created_at: token.createdAt,
};
}
function serializeAgentSession(session: AgentSessionRecord): Record<string, unknown> {
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))],
};
}

20
src/security/bearer.ts Normal file
View File

@ -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;
}