Skip to content

feat: authenticate service-to-API calls (run token + service keys) - #1558

Closed
Josh Duffney (duffney) wants to merge 8 commits into
duffney-authenticated-delivery-extractionfrom
duffney-service-auth-run-token
Closed

Josh Duffney (duffney) wants to merge 8 commits into
duffney-authenticated-delivery-extractionfrom
duffney-service-auth-run-token

Conversation

@duffney

@duffney Josh Duffney (duffney) commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Authenticates service-to-API calls. Authentication only: route-level authorization (RBAC v2) will be stacked on top of this PR.

Alternative stack: main ← #1531 ← this PR ← RBAC v2 (next). It is an alternative to #1532. The existing stack #1531 ← #1532 is unchanged and remains the fallback.

Refs #1557

Run tokens

  • A worker mints an opaque scope_rt_<requestId>_<secret><checksum> token when it claims a run. The secret is 256 bits; the checksum is CRC-32.
  • Only a SHA-256 digest is stored, written atomically with the claim as run.tokenHash. The worker's terminal writes $unset it.
  • Worker API clients send the token as a bearer: MCP servers, skills, extensions, resources, prompts, codebases and judge.
  • The judge forwards the token to a criteria provider built for each evaluation.
  • The API accepts the token only while all of these hold:
    • the run is processing;
    • the digest matches (constant-time compare);
    • the run is within RUN_TOKEN_MAX_AGE_MS.
  • Otherwise the API returns 401.
  • 14 worker read routes carry metadata-only runToken: true (exposed as x-scope-run-token in OpenAPI).
  • run.tokenHash is removed from all run responses and history.

Per-service keys

  • register-agents and the model scanners send X-Service-Name and X-Internal-Key, but only when SCOPE_INTERNAL_API_KEY is set.
  • The API reads its keys from INTERNAL_API_KEY_REGISTER_AGENTS and INTERNAL_API_KEY_MODEL_SCANNER. Keys must be at least 32 characters and are compared in constant time.
  • Missing configuration or a wrong key fails closed (401).
  • Scopes are attached to the caller but not yet enforced; enforcement arrives with RBAC v2.

Caller identity

  • The middleware attaches a separate req.principal (user | run | service).
  • req.auth and req.user keep their existing semantics. Run and service callers get the anonymous user and no req.auth.
  • Requests without a token behave exactly as before.

Implementation choices

  • Ambiguous credentials: service headers sent together with Authorization return 400 ambiguous_credentials.
  • Auth disabled: when IdP auth is disabled, credentials are ignored, preserving existing behavior.
  • Token storage: run tokens are held in memory per claim and cleared in a finally block.
  • register-agents key: the script passes its key to curl through a private header file (umask 077, removed on exit).
  • Model-scanner scopes: agents:read, agents:register, models:sync.
  • Follow-ups: post-run callers (post-processor report trigger, report-generator tools) don't send a run token yet.
  • Deployment: Compose is wired; Kubernetes manifests and key provisioning live outside this repository.

Demos

N/A: no Portal or CLI changes.

Before

N/A

After

N/A

Testing

  • pnpm --filter "./packages/**" build: passed
  • pnpm exec tsc --noEmit -p . in apps/api, apps/judge, packages/shared, packages/model-scanning, apps/workers/coder-acp-copilot, apps/workers/coder-acp-claude-code, apps/model-scanners/copilot and apps/model-scanners/anthropic: passed
  • pnpm exec vitest run apps/api packages/shared apps/judge packages/model-scanning scripts/register-agent.test.ts apps/workers: passed (133 files, 2216 tests)
  • docker compose config -q: passed
  • pnpm generate:openapi (in apps/api): regenerated website/src/openapi/scope-openapi.json; the snapshot test passes
  • Package lint scripts were not run: eslint is not installed in the workspace (eslint: command not found).

Documentation and compatibility

  • docs/architecture/auth-rbac.md §6 covers run tokens and per-service keys, with the authentication flow, failure codes and the service table.
  • ENV_VARIABLES.md adds RUN_TOKEN_MAX_AGE_MS, INTERNAL_API_KEY_REGISTER_AGENTS, INTERNAL_API_KEY_MODEL_SCANNER and SCOPE_INTERNAL_API_KEY. .env.example is updated.
  • Compatibility: no change for requests without a token.
  • Upgrade step: to identify register-agents and the scanners, set the two internal API keys on the API and the matching SCOPE_INTERNAL_API_KEY on each caller. A caller without a key stays anonymous, as before. A caller that sends a key the API doesn't have configured gets 401.

Checklist

  • N/A: If Portal features changed, keep CLI capabilities in sync.
  • N/A: If Portal components changed, update their Storybook stories.
  • N/A: If database changes require a migration, include up() / down() and keep it CosmosDB-compatible. (run.tokenHash is optional and transient.)
  • N/A: If dependencies changed, update the lockfile and regenerate NOTICE / NOTICE-REVIEW.txt with pnpm notice as needed.
  • N/A: Video showing the behavior before the suggested change
  • N/A: Video showing the behavior after the suggested change

Add an opaque per-run bearer credential used by workers when calling the API:

- `scope_rt_<requestId>_<secret><checksum>` with 32 random bytes of secret and
  a base62 CRC32 checksum so malformed tokens are rejected without a DB read.
- Only a hex SHA-256 digest is meant to be persisted; digests are compared in
  constant time.
- `BearerProvider`, `bearerHeaders` and `fetchWithBearer` let API clients add
  `Authorization: Bearer` only when a token is available, leaving existing
  no-token calls unchanged.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Recognize two non-user credentials before IdP verification and attach them as
a separate `req.principal`, leaving `req.auth`/`req.user` semantics unchanged:

- `Authorization: Bearer scope_rt_…` is verified with a point read on the
  request: hash must match `run.tokenHash`, the run must be `processing`, and
  the claim must be younger than RUN_TOKEN_MAX_AGE_MS (default 24h). Forged,
  inactive or expired tokens return 401; a DB failure returns 503. The IdP is
  never called for run tokens.
- `X-Service-Name` + `X-Internal-Key` are verified against per-service keys
  (INTERNAL_API_KEY_REGISTER_AGENTS, INTERNAL_API_KEY_MODEL_SCANNER). Missing
  or short keys disable that service (fail closed). Scopes are recorded on the
  principal only; enforcement is left to the authorization layer.
- Sending both service headers and a bearer returns 400 ambiguous_credentials.
- When auth is disabled, all credentials are ignored as before.

Also adds `getPrincipal(req)`, server-only `RunState.tokenHash` (kept out of
the zod schema) and a metadata-only `runToken` route flag that emits
`x-scope-run-token` in OpenAPI. Requests without credentials behave exactly
as before.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…nHash

- Flag the 14 GET routes workers and the judge call during a run with
  `runToken: true` (MCP server, skill revision by ref + archive, extension,
  resource + revisions, task prompt + content, codebase revision + archive,
  criteria list + by id). An inventory test pins the exact list.
- Never expose the run-token digest: strip `run.tokenHash` from request
  get/list, attempt list/detail, history snapshots and run archives.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ents

The queue processor mints an opaque run token when it atomically claims a
run, persists only its SHA-256 digest in the claim ($set run.tokenHash),
and keeps the raw token in memory for the life of the handling. MCP,
skill, extension, resource, prompt, codebase, gate-prompt and judge
clients send it as a bearer via an optional BearerProvider. The digest is
unset on the terminal success and error writes, and the in-memory token is
dropped when handling ends.

Clients without a bearer behave exactly as before.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The judge extracts a well-formed run token from the evaluate request's
Authorization header and, when present, builds a per-evaluation REST
criteria provider that sends it as a bearer. Requests without a run token
keep using the cached per-project provider. RestApiCriteriaProvider and
createCriteriaProvider accept an optional BearerProvider.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
register-agent.sh sends X-Service-Name: register-agents and X-Internal-Key
from a private header file when SCOPE_INTERNAL_API_KEY is set, so the key
never appears on the curl command line. Model-scanner fetch/upsert/sync
calls send the model-scanner principal headers the same way. Without a key
both callers behave exactly as before.

Compose passes INTERNAL_API_KEY_REGISTER_AGENTS and
INTERNAL_API_KEY_MODEL_SCANNER to the API and the matching key to each
caller; .env.example documents the variables. A missing key on the API side
disables that service principal (fail closed). Scopes are identified only;
enforcement lands with RBAC.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds unit tests for the run-token verifier (valid claim, malformed,
forged, deleted/missing digest, not processing, expired, store
unavailable), the per-service key verifier (per-service keys and scopes,
wrong/foreign/unknown keys, fail closed when unset or too short), and the
auth middleware (run and service callers attached and logged without
secrets, IdP and user access skipped, 401/400/503 mappings, no-token
requests still anonymous, credentials ignored while auth is disabled).

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
auth-rbac.md §6 now describes the implemented run token (mint at claim,
digest at rest, verification rules, failure codes, redaction, runToken
route metadata) and per-service keys (headers, fail-closed rules, service
table), and notes that enforcement arrives with route-level
authorization. ENV_VARIABLES.md documents RUN_TOKEN_MAX_AGE_MS,
INTERNAL_API_KEY_REGISTER_AGENTS, INTERNAL_API_KEY_MODEL_SCANNER and
SCOPE_INTERNAL_API_KEY.

Refs #1557

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added type: documentation Documentation additions, corrections, and improvements. area: worker Coding agent worker runtime, task execution, and lifecycle. area: cicd Build, test, release, and deployment pipelines. area: infrastructure Cloud resources, hosting, networking, and shared infrastructure. language: javascript Work involving JavaScript code, tooling, or dependencies. area: judge Evaluation engine, scoring, and criteria execution. topic: security Security protections, vulnerabilities, and risk reduction. topic: testing Test coverage, test infrastructure, and validation quality. topic: ui Portal, visual presentation, layout, and interface components. labels Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cicd Build, test, release, and deployment pipelines. area: infrastructure Cloud resources, hosting, networking, and shared infrastructure. area: judge Evaluation engine, scoring, and criteria execution. area: worker Coding agent worker runtime, task execution, and lifecycle. language: javascript Work involving JavaScript code, tooling, or dependencies. topic: security Security protections, vulnerabilities, and risk reduction. topic: testing Test coverage, test infrastructure, and validation quality. topic: ui Portal, visual presentation, layout, and interface components. type: documentation Documentation additions, corrections, and improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant