API - CODEX AGENTS: bearer session auth
This commit is contained in:
parent
112522c423
commit
14c5f490b1
12
README.md
12
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/<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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
10
src/app.ts
10
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<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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue