Skip to content

Accept the MCP OAuth bearer on staged upload file writes - #94

Open
dniasoff wants to merge 1 commit into
plannotator:mainfrom
orvexai:fix/oidc-mcp-upload-bearer
Open

dniasoff wants to merge 1 commit into
plannotator:mainfrom
orvexai:fix/oidc-mcp-upload-bearer

Conversation

@dniasoff

Copy link
Copy Markdown

Problem

With a hosted OAuth provider (generic OIDC or WorkOS), /mcp accepts the end-user access token for <origin>/mcp. artifact_create_upload then returns upload URLs under /api/v1/uploads/.../files/... and tells the agent to PUT each file with reuse_the_mcp_bearer_credential. But /api bearer auth (authenticateApiBearer) accepts only managed as_key_ keys and the local token, so that PUT fails with 401 A valid Artifact Server API key is required.

So an agent connected only through OAuth can plan an upload but never publish. We hit this on a self-hosted Keycloak deployment (v0.1.2): an OAuth device-grant token with the exact /mcp audience got through initialize, tools/list, artifact_capabilities and artifact_create_upload, then got a 401 on the file PUT.

Change

The staged-upload file write (PUT /api/v1/uploads/:uploadId/files/:storageToken, the URL the MCP upload plan hands out) now authenticates its bearer exactly as /mcp does (authenticateMcpBearer):

  • Credentials: a superset of the API path for that one route. The same local token and managed keys are still accepted, plus the hosted access token for the exact <origin>/mcp resource, with the same issuer, audience, token-type and expiry checks.
  • Authorization: unchanged. StagedUploadService still binds every file to the principal that created the plan.
  • Scope: every other /api route keeps the API-credential contract.

Docs: docs/deployment.md (generic OIDC section) and docs/mcp.md (publish steps) now state that the upload PUT uses the MCP bearer.

Requirement IDs

MCP-013 (self-hosted MCP through a generic OIDC issuer). The new tests carry no ID prefix, because MCP-013-B/F are already claimed in oidc-mcp-authorization.test.ts and check-conformance-test-ids.rb allows one test per ID.

Tests

New, in tests/conformance/oidc-mcp-authorization.test.ts:

  • an OIDC access token uploads and publishes the files of its own MCP upload plan: plans over MCP, PUTs with the OIDC token, commits. Fails without this change (401 on the PUT).
  • an OIDC access token opens no other API route, no other principal's upload, and no wrong resource:
    • the same token gets 401 on GET /api/v1/api-keys and POST /api/v1/uploads;
    • it cannot write an upload created by another principal;
    • wrong-audience, /mcp-of-another-origin, ID-token and expired tokens get 401 on the file write.

Ran:

  • pnpm lint, pnpm typecheck: pass.
  • ruby scripts/check-conformance-test-ids.rb: "Valid conformance test mapping: 256 acceptance tests have one implementation test."
  • pnpm exec vitest run on the oidc-mcp-authorization, staged-upload-lifecycle, mcp-modern-http, workos-mcp-authorization and installation-identity suites: 34/34 pass.
  • Full vitest run: 328/331. The 3 failures are in tests/cli/* and are unrelated:
    • two timed out only under full-suite load and pass when run alone;
    • local-cli.test.ts > opens the managed application at a credential-free URL fails identically on unmodified v0.1.2 in this environment.

Not run: pnpm verify:iteration (needs Docker image pulls not available here) and test:oidc (real Keycloak harness). I did verify the behaviour against a real Keycloak 26.5 deployment with the patched image; details in a follow-up comment.

Deployment effects

None beyond the above: no migration and no configuration change. Operators of OIDC/WorkOS installations get OAuth-only MCP publishing.

An MCP upload plan tells the agent to PUT each file with the bearer it used
on /mcp ("reuse_the_mcp_bearer_credential"). With a hosted OAuth provider
(generic OIDC or WorkOS), /mcp accepts the end-user access token, but the
upload URL is under /api, whose bearer check accepts only managed API keys.
So an agent signed in only through OAuth could plan an upload but never
write its files, and could not publish.

The staged-upload file write (PUT /api/v1/uploads/:uploadId/files/:token)
now authenticates its bearer exactly as /mcp does. That is a superset of the
API-credential path for that route: the same local token and managed keys,
plus the hosted access token for the exact <origin>/mcp resource. Upload
ownership is unchanged, because StagedUploadService still binds every file
to the principal that created the plan. Every other API route keeps the
API-credential contract.

Tests (generic OIDC):
- an OIDC access token plans, uploads and publishes over MCP
  (fails before this change with 401 on the file write);
- the same token gets 401 on other API routes (api-keys, REST upload
  creation), cannot write another principal's upload, and wrong-audience,
  ID, and expired tokens are refused on the file write.
@dniasoff

Copy link
Copy Markdown
Author

Verified against a real deployment: Keycloak 26.5, Helm chart v0.1.2, Postgres, S3-compatible object storage. The image was v0.1.2 plus this commit.

  • The client got its access token through the OAuth 2.0 device authorization grant with S256 PKCE. Its aud was exactly <origin>/mcp (Keycloak audience mapper), with email_verified=true.
  • Over Streamable HTTP, initialize, tools/list, artifact_capabilities and artifact_create_upload all succeeded with that token.
  • PUT of the file to the returned uploadUrl with the same bearer:
    • unmodified v0.1.2: 401 A valid Artifact Server API key is required.
    • with this change: 200.
  • artifact_commit_upload then published version 1, served on the content domain.

Separately, unrelated to this PR: v0.1.2's packaging/oci/Dockerfile no longer builds. AWS republished https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem (Last-Modified 2026-09-29), so ADD --checksum=sha256:e5bb2084... fails with a digest mismatch; the current file is sha256:fe45bbebf92ad3e27a583bbb2ddd1553c521ed4d49af5514dc0a40372ea5395c. Also, the chart's cleanup CronJob passes no --port. When the release is named artifact-server, Kubernetes service links inject ARTIFACT_SERVER_PORT=tcp://<ip>:80 and every run fails with The port must be an integer between 0 and 65535. Setting enableServiceLinks: false on the pod specs fixes it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant