Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,15 @@ FEEDBACK_DESCENDANT_GUARD=true
# Fixed active-user cache lifetime, in seconds (positive integer, default 300).
# Cache hits do not extend expiry; Redis outages fall back to MongoDB.
# AUTH_USER_CACHE_TTL_SECONDS=300

# Service-to-API authentication (only used when AUTH_PROVIDER is set).
# Per-service keys (>= 32 characters each). The API reads them to recognise each
# service principal; Compose passes the same value to the matching caller as
# SCOPE_INTERNAL_API_KEY. Unset = that service's credentials are rejected.
# Generate with: openssl rand -hex 32
# INTERNAL_API_KEY_REGISTER_AGENTS=
# INTERNAL_API_KEY_MODEL_SCANNER=

# Absolute maximum age of a run token, in ms, measured from the run's claim
# (default 86400000 = 24h). Run tokens are only valid while the run is processing.
# RUN_TOKEN_MAX_AGE_MS=86400000
39 changes: 39 additions & 0 deletions ENV_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,45 @@ identity is promoted only when its tenant is explicitly listed here.
> secret). A later On-Behalf-Of enrichment would introduce
> `AUTH_API_CLIENT_SECRET`; it is **not** used now.

### RUN_TOKEN_MAX_AGE_MS
**Default:** `86400000` (24h)
**Type:** positive number (milliseconds)
**Scope:** API

Absolute maximum age of a worker run token, measured from the run's claim
(`run.startedAt`). A run token is accepted only while its run is `processing` under
the claim that minted it **and** within this age. Missing or invalid values fall back
to the default. See [auth-rbac.md §6](docs/architecture/auth-rbac.md).

### INTERNAL_API_KEY_REGISTER_AGENTS
**Default:** (not set — service disabled)
**Type:** string, at least 32 characters
**Scope:** API

Key for the `register-agents` service principal (scope `agents:register`). The API
accepts `X-Service-Name: register-agents` + `X-Internal-Key: <key>` only when this is
set; unset or shorter keys disable the principal (fail closed). Only used when
`AUTH_PROVIDER` is configured. Generate with `openssl rand -hex 32`.

### INTERNAL_API_KEY_MODEL_SCANNER
**Default:** (not set — service disabled)
**Type:** string, at least 32 characters
**Scope:** API

Key for the `model-scanner` service principal (scopes `agents:read`,
`agents:register`, `models:sync`). Same rules as `INTERNAL_API_KEY_REGISTER_AGENTS`.

### SCOPE_INTERNAL_API_KEY
**Default:** (not set)
**Type:** string
**Scope:** `register-agents` script, model scanners

The calling service's own key. When set, `scripts/register-agent.sh` sends the
`register-agents` service headers and the model scanners send the `model-scanner`
service headers; when unset they send no service headers (unchanged behavior). Docker
Compose maps `INTERNAL_API_KEY_REGISTER_AGENTS` / `INTERNAL_API_KEY_MODEL_SCANNER`
from `.env` to the matching caller.

## Token Manager Configuration

### TOKEN_MANAGER_URL
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4646,6 +4646,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Codebase Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/codebase-revisions/{id}/archive": {
Expand All @@ -4665,6 +4666,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Codebase Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/codebases": {
Expand Down Expand Up @@ -4957,6 +4959,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Criteria",
],
"x-scope-run-token": true,
},
"post": {
"parameters": [
Expand Down Expand Up @@ -5378,6 +5381,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Criteria",
],
"x-scope-run-token": true,
},
"put": {
"parameters": [
Expand Down Expand Up @@ -5657,6 +5661,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Extensions",
],
"x-scope-run-token": true,
},
"put": {
"parameters": [
Expand Down Expand Up @@ -6673,6 +6678,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"MCP Servers",
],
"x-scope-run-token": true,
},
"put": {
"parameters": [
Expand Down Expand Up @@ -10583,6 +10589,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Resource Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/resources/{id}": {
Expand Down Expand Up @@ -10664,6 +10671,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Resources",
],
"x-scope-run-token": true,
},
"patch": {
"parameters": [
Expand Down Expand Up @@ -10865,6 +10873,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Resource Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/resources/{id}/revisions/{revisionNumber}": {
Expand Down Expand Up @@ -10918,6 +10927,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Resource Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/runs/upload": {
Expand Down Expand Up @@ -11012,6 +11022,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Skill Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/skill-revisions/by-ref/{ref}(*)/archive": {
Expand Down Expand Up @@ -11045,6 +11056,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Skill Revisions",
],
"x-scope-run-token": true,
},
},
"/api/v1/skill-revisions/{id}": {
Expand Down Expand Up @@ -11726,6 +11738,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Task Prompts",
],
"x-scope-run-token": true,
},
},
"/api/v1/task-prompts/{id}/content": {
Expand Down Expand Up @@ -11771,6 +11784,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"tags": [
"Task Prompts",
],
"x-scope-run-token": true,
},
},
"/api/v1/task-prompts/{id}/extract-features": {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/archive-har.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export function rewriteHarUrlsForArchive<T extends {
}>(resource: T): T {
const copy = JSON.parse(JSON.stringify(resource));
if (copy.run) {
// Archives are user-facing; drop the server-only run-token digest.
delete copy.run.tokenHash;
if (copy.run.harUrl) {
copy.run.harUrl = "run.har";
}
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/auth/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import type { ErrorRequestHandler } from "express";
import { AuthError } from "shared";
import { AmbiguousCredentialsError } from "./middleware.js";
import { UserAccessError } from "./user-access-resolver.js";

/** Keep login and normal-request authentication failures on the same HTTP contract. */
Expand All @@ -17,6 +18,10 @@ export const authErrorHandler: ErrorRequestHandler = (
});
return;
}
if (err instanceof AmbiguousCredentialsError) {
res.status(err.status).json({ error: err.message, code: err.code });
return;
}
if (err instanceof UserAccessError) {
res.status(err.status).json({ error: err.message, code: err.code });
return;
Expand Down
181 changes: 179 additions & 2 deletions apps/api/src/auth/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { AuthError, type AuthProvider, type VerifiedIdentity } from "shared";
import { AuthError, mintRunToken, type AuthProvider, type VerifiedIdentity } from "shared";
import { createAuthMiddleware, createUserAccessMiddleware } from "./middleware.js";
import { authErrorHandler } from "./error-handler.js";
import { getUser } from "./types.js";
import { getPrincipal, getUser } from "./types.js";
import { UserAccessError, type UserAccessService } from "./user-access-resolver.js";

const identity: VerifiedIdentity = {
Expand Down Expand Up @@ -156,3 +156,180 @@ describe("IdP verification and application access middleware", () => {
expect(res.body.code).toBe(code);
});
});

describe("run-token and service-key callers", () => {
const runToken = mintRunToken("req-1").token;
const verifiedRun = { requestId: "req-1", runId: "run-1", projectId: "project-1", tokenHash: "abcdef0123456789" };

function setupCallers(opts: { providerEnabled?: boolean; withVerifiers?: boolean } = {}) {
const { providerEnabled = true, withVerifiers = true } = opts;
const provider = {
id: "entra",
verifyAccessToken: vi.fn(async () => identity),
} satisfies AuthProvider;
const resolver = {
resolveExisting: vi.fn(async () => principal),
enrollOnLogin: vi.fn(async () => principal),
} satisfies UserAccessService;
const runVerifier = vi.fn(async (token: string) => {
if (token !== runToken) throw new AuthError("invalid_run_token", "Invalid run token");
return verifiedRun;
});
const serviceVerifier = vi.fn((name: string, key: string) => {
if (name !== "register-agents" || key !== "k".repeat(40)) {
throw new AuthError("invalid_service_key", "Invalid service credentials");
}
return { name, scopes: ["agents:register"] };
});
const app = express();
app.use(createAuthMiddleware({
getProvider: () => providerEnabled ? provider : null,
getRunTokenVerifier: () => withVerifiers ? runVerifier : null,
getServiceKeyVerifier: () => withVerifiers ? serviceVerifier : null,
}));
app.use(createUserAccessMiddleware(() => resolver));
app.get("/api/v1/private", (req, res) => {
res.json({ principal: getPrincipal(req), user: getUser(req), hasAuth: req.auth !== undefined });
});
app.use(authErrorHandler);
return { app, provider, resolver, runVerifier, serviceVerifier };
}

it("attaches a verified run caller without IdP verification or user access", async () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
const { app, provider, resolver, runVerifier } = setupCallers();
const res = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${runToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({
principal: { kind: "run", requestId: "req-1", runId: "run-1", projectId: "project-1" },
user: { id: "anonymous", isAuthenticated: false },
hasAuth: false,
});
expect(runVerifier).toHaveBeenCalledWith(runToken);
expect(provider.verifyAccessToken).not.toHaveBeenCalled();
expect(resolver.resolveExisting).not.toHaveBeenCalled();
// The caller is logged with an 8-char digest prefix, never the raw token.
const logged = debug.mock.calls.map((c) => String(c[0])).join("\n");
expect(logged).toContain("run caller request=req-1 run=run-1 token=abcdef01");
expect(logged).not.toContain(runToken);
debug.mockRestore();
});

it.each([
["invalid_run_token"],
["run_not_active"],
["run_token_expired"],
] as const)("rejects a run token failing with %s as 401", async (code) => {
const { app, runVerifier, provider } = setupCallers();
runVerifier.mockRejectedValueOnce(new AuthError(code, "nope"));
const res = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${runToken}`);
expect(res.status).toBe(401);
expect(res.body.code).toBe(code);
expect(provider.verifyAccessToken).not.toHaveBeenCalled();
});

it("rejects a forged run token as 401", async () => {
const { app } = setupCallers();
const forged = mintRunToken("req-1").token;
const res = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${forged}`);
expect(res.status).toBe(401);
expect(res.body.code).toBe("invalid_run_token");
});

it("returns 503 when run-token verification is unavailable", async () => {
const { app, runVerifier } = setupCallers();
runVerifier.mockRejectedValueOnce(new AuthError("service_unavailable", "down"));
const res = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${runToken}`);
expect(res.status).toBe(503);
});

it("rejects run tokens when no verifier is configured", async () => {
const { app } = setupCallers({ withVerifiers: false });
const res = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${runToken}`);
expect(res.status).toBe(401);
expect(res.body.code).toBe("invalid_run_token");
});

it("attaches a verified service caller", async () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
const { app, provider, resolver } = setupCallers();
const res = await request(app)
.get("/api/v1/private")
.set("X-Service-Name", "register-agents")
.set("X-Internal-Key", "k".repeat(40));
expect(res.status).toBe(200);
expect(res.body.principal).toEqual({ kind: "service", name: "register-agents", scopes: ["agents:register"] });
expect(res.body.user).toEqual({ id: "anonymous", isAuthenticated: false });
expect(provider.verifyAccessToken).not.toHaveBeenCalled();
expect(resolver.resolveExisting).not.toHaveBeenCalled();
expect(debug.mock.calls.map((c) => String(c[0])).join("\n")).not.toContain("k".repeat(40));
debug.mockRestore();
});

it.each([
[{ "X-Service-Name": "register-agents", "X-Internal-Key": "wrong" }],
[{ "X-Service-Name": "register-agents" }],
[{ "X-Internal-Key": "k".repeat(40) }],
])("rejects incomplete or wrong service credentials %j as 401", async (headers) => {
const { app } = setupCallers();
const res = await request(app).get("/api/v1/private").set(headers);
expect(res.status).toBe(401);
expect(res.body.code).toBe("invalid_service_key");
});

it("fails closed for service headers when no service verifier is configured", async () => {
const { app } = setupCallers({ withVerifiers: false });
const res = await request(app)
.get("/api/v1/private")
.set("X-Service-Name", "register-agents")
.set("X-Internal-Key", "k".repeat(40));
expect(res.status).toBe(401);
});

it("rejects service headers combined with an Authorization header", async () => {
const { app, serviceVerifier, provider } = setupCallers();
const res = await request(app)
.get("/api/v1/private")
.set("Authorization", `Bearer ${runToken}`)
.set("X-Service-Name", "register-agents")
.set("X-Internal-Key", "k".repeat(40));
expect(res.status).toBe(400);
expect(res.body.code).toBe("ambiguous_credentials");
expect(serviceVerifier).not.toHaveBeenCalled();
expect(provider.verifyAccessToken).not.toHaveBeenCalled();
});

it("keeps no-credential requests anonymous", async () => {
const { app, runVerifier, serviceVerifier } = setupCallers();
const res = await request(app).get("/api/v1/private");
expect(res.status).toBe(200);
expect(res.body.principal).toEqual({ kind: "anonymous" });
expect(runVerifier).not.toHaveBeenCalled();
expect(serviceVerifier).not.toHaveBeenCalled();
});

it("still sends IdP bearers to the provider and resolves the user", async () => {
const { app, provider, runVerifier } = setupCallers();
const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer idp-token");
expect(res.status).toBe(200);
expect(res.body.principal).toEqual({ kind: "user", user: principal });
expect(res.body.hasAuth).toBe(true);
expect(provider.verifyAccessToken).toHaveBeenCalledWith("idp-token");
expect(runVerifier).not.toHaveBeenCalled();
});

it("ignores run and service credentials while auth is disabled", async () => {
const { app, runVerifier, serviceVerifier } = setupCallers({ providerEnabled: false });
const run = await request(app).get("/api/v1/private").set("Authorization", `Bearer ${runToken}`);
const svc = await request(app)
.get("/api/v1/private")
.set("X-Service-Name", "register-agents")
.set("X-Internal-Key", "wrong");
for (const res of [run, svc]) {
expect(res.status).toBe(200);
expect(res.body.principal).toEqual({ kind: "anonymous" });
}
expect(runVerifier).not.toHaveBeenCalled();
expect(serviceVerifier).not.toHaveBeenCalled();
});
});
Loading
Loading