Skip to content

feat: add authenticated HTTP transport for DWS MCP#20

Closed
jdrhyne wants to merge 3 commits intomainfrom
cowork-dws/C-runtime-transport
Closed

feat: add authenticated HTTP transport for DWS MCP#20
jdrhyne wants to merge 3 commits intomainfrom
cowork-dws/C-runtime-transport

Conversation

@jdrhyne
Copy link
Contributor

@jdrhyne jdrhyne commented Mar 7, 2026

Summary

  • add Streamable HTTP MCP support at /mcp while preserving stdio mode
  • add bearer-authenticated remote access, session-bound principal handling, and tool allowlists
  • add health checks, transport/auth tests, and GHCR image publish automation for Hosted rollout

Why

Co-work needs a remotely deployable DWS MCP service. The existing server was stdio only.

Validation

  • pnpm build
  • pnpm lint
  • pnpm test
  • live DWS check_credits smoke test

Linked Hosted PR

  • PSPDFKit/PSPDFKit#51223

Notes

  • long live example suites are now opt-in so default test runs exit cleanly
  • the Hosted deployment wiring lives in a separate PR in PSPDFKit/PSPDFKit
  • no production deployment is proposed from this PR alone

jdrhyne added 3 commits March 7, 2026 01:27
Add streamable HTTP MCP support alongside stdio mode for the DWS connector.\n\nThis adds bearer-authenticated remote transport, session-bound tool filtering, health checks, targeted transport tests, and GHCR publish automation for Hosted deployment.
Remove the setup-node pnpm cache configuration so GitHub Actions no longer fails before Corepack enables pnpm.
Add linux to pnpm supported architectures so CI installs Rollup's Linux native package for Vitest.
Copy link

@lazyoldbear lazyoldbear left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait. So it's just list of preconfigured hardcoded credentials in MCP_BEARER_TOKENS_JSON or am I missing something?

@tomassurin
Copy link

Very rough spec:

DWS MCP Authorization Flow Specification

Context

Current gap:

  • Inbound auth to MCP is bearer-based, but outbound MCP -> DWS uses a single static NUTRIENT_DWS_API_KEY.
  • This must be replaced with delegated, per-user/per-org authorization.

Decision Summary

Use an OAuth-style delegated model with two token types:

  1. mcp_access_token for Co-work -> MCP authentication.
  2. Short-lived dws_runtime_token (JWT) for MCP -> DWS API via token exchange.

Do not store/reuse long-lived DWS API keys in MCP.

Goals

  • Remove shared long-lived API key usage in MCP runtime.
  • Preserve per-user and per-organization identity across the full chain.
  • Enforce least privilege via tool and operation scoping.
  • Support revocation and rotation without service redeploys.
  • Keep MCP deployment secrets limited to OAuth client credentials and issuer metadata.

Non-Goals

  • No long-term persistence of end-user API keys in Co-work or MCP.
  • No custom one-off redirect header protocol; use standard OAuth/OIDC patterns.

Co-work User Perspective Flow

sequenceDiagram
  autonumber
  actor U as Co-work User
  participant C as Co-work
  participant A as DWS Auth
  participant M as MCP Server
  participant P as DWS Processor API

  U->>C: Click Connect DWS
  C->>A: Redirect to /oauth/authorize (PKCE)
  A-->>U: Sign in or sign up and consent
  A-->>C: Redirect back with auth code
  C->>A: POST /oauth/token (authorization_code + verifier)
  A-->>C: mcp_access_token + refresh_token

  U->>C: Ask to process a document
  C->>M: POST /mcp with mcp_access_token
  M->>A: POST /oauth/token (token_exchange)
  A-->>M: dws_runtime_token (short lived)
  M->>P: Call /build or /sign with dws_runtime_token
  P-->>M: Processing result
  M-->>C: MCP tool result
  C-->>U: Show output

  Note over C,A: Co-work silently refreshes mcp_access_token when needed
  U->>C: Disconnect DWS
  C->>A: POST /oauth/revoke (refresh_token)
Loading

Token Model

1) mcp_access_token (JWT, ~5 minutes)

Issued to Co-work after user authentication and consent.

Example claims:

{
  "iss": "https://api.nutrient.io",
  "aud": "dws-mcp",
  "sub": "account:<account_id>",
  "azp": "cowork",
  "organization_id": "<org_uuid>",
  "tenant_id": "<tenant_public_id>",
  "request_kind": "live",
  "allowed_tools": ["document_processor", "document_signer", "ai_redactor", "check_credits"],
  "allowed_operations": ["...Hosted.DocumentEngine.Features names..."],
  "scope": "mcp:invoke",
  "sid": "<session_id>",
  "jti": "<uuid>",
  "iat": 0,
  "nbf": 0,
  "exp": 0
}

2) dws_runtime_token (JWT, ~60-120 seconds)

Issued only to MCP by token exchange and used for calls to DWS Processor endpoints.

Example claims:

{
  "organization_id": "<org_uuid>",
  "tenant_id": "<tenant_public_id>",
  "request_kind": "live",
  "allowed_operations": ["..."],
  "actor_sub": "account:<account_id>",
  "actor_azp": "cowork",
  "mcp_session_id": "<mcp-session-id>",
  "id": "<uuid>",
  "iat": 0,
  "nbf": 0,
  "exp": 0
}

Protocol Endpoints

GET /oauth/authorize

Purpose: authenticate user (sign in/up) and collect consent.

Query:

  • response_type=code
  • client_id
  • redirect_uri
  • scope
  • state
  • code_challenge
  • code_challenge_method=S256

Response: redirect back to Co-work with code and state.

POST /oauth/token (authorization code)

Request:

{
  "grant_type": "authorization_code",
  "code": "<code>",
  "redirect_uri": "<redirect_uri>",
  "client_id": "cowork",
  "code_verifier": "<pkce_verifier>"
}

Response:

{
  "access_token": "<mcp_access_token>",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "<opaque_or_jwt>",
  "scope": "mcp:invoke"
}

POST /oauth/token (refresh)

Request:

{
  "grant_type": "refresh_token",
  "refresh_token": "<refresh_token>",
  "client_id": "cowork"
}

POST /oauth/token (token exchange for MCP)

Auth: confidential MCP client auth (private_key_jwt preferred; client_secret acceptable initially).

Request:

{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": "<mcp_access_token>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "audience": "dws-api",
  "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "mcp_session_id": "<mcp-session-id>"
}

Response:

{
  "access_token": "<dws_runtime_token>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 120
}

POST /oauth/revoke

Purpose: revoke refresh token on disconnect/sign-out.

Discovery and Key Distribution

  • GET /.well-known/openid-configuration
  • GET /.well-known/jwks.json

Required for MCP JWT validation and signing-key rotation.

MCP Runtime Enforcement Rules

  • Validate incoming mcp_access_token against issuer JWKS.
  • Require:
    • aud = dws-mcp
    • scope includes mcp:invoke
  • Enforce tool visibility/execution from allowed_tools.
  • Bind MCP session to stable principal fingerprint:
    • sha256(sub|azp|sid)
    • Do not bind to raw access token bytes (supports refresh rotation).
  • Exchange for dws_runtime_token per request, or cache for max 60-120 seconds.
  • Never expose or persist long-lived DWS API keys for runtime processing.

TODO

  1. Introduce OAuth/OIDC issuer endpoints and Co-work auth-code+PKCE flow.
  2. Implement MCP-side JWT validation and policy mapping from token claims.
  3. Implement token exchange to get short-lived dws_runtime_token.
  4. Switch MCP outbound calls from static API key to exchanged runtime token.
  5. Remove Terraform secrets:
    • dws_mcp_api_key
    • dws_mcp_bearer_tokens_json
  6. Add Terraform/runtime config for:
    • OIDC issuer/discovery/JWKS endpoints
    • MCP OAuth client credentials
    • token-exchange audience and policy settings

Existing Hosted Capabilities to Reuse

  • JWT generation/claims/revocation:
    • hosted/lib/hosted/iam/jwt.ex
  • JWT verification + auth wiring:
    • hosted/lib/hosted_web/api_auth.ex
  • Processor routes already accepting API token and DWS JWT:
    • review/hosted/lib/hosted_web/router.ex

Explicit Anti-Pattern

Do not build MCP runtime auth by retrieving/storing processor API keys. These are long-lived live API keys that reintroduces shared-secret risk in infrastructure and logs. With only way to revoke access being manually rotating them via DWSP dashboard.

Note: If we are fine with storing the API keys in cowork, we can simplify and let DWS redirect back to cowork with API key and simplify the whole oauth flow with simply passing the API key stored in cowork to MCP server. IMHO, not perfect but at least there is some form of authorization for tenants and it's good enough for MVP in this vibe-code/move fast break things world.

@tomassurin
Copy link

Superseded by #21

@tomassurin tomassurin closed this Mar 11, 2026
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.

3 participants