feat: MCP server with an OAuth 2.1 provider #2

Merged
ulysia merged 1 commit from testing into main 2026-08-31 19:26:01 +02:00
Owner

Closes the MCP OAuth gap the upgrade surfaced, and builds the MCP server it
was the authentication half of. Core ships no MCP server at all — only the
route exclusions in main.ts and the workspace settings — so this is the server,
not an adapter for one.

The design is upstream's, read off the parts it does ship, rather than
invented: the four oauth_* tables specify JWT access tokens with a jti row,
hashed opaque refresh tokens and per-(user,client) grants; the consent client
in apps/client/src/ee/oauth/ fixes the four endpoints, the two scopes and the
error shape; main.ts fixes the URLs.

oauth/

  • OAuth 2.1: authorization code with mandatory PKCE (S256 only), refresh with
    rotation, RFC 7591 dynamic registration, RFC 8414 + RFC 9728 discovery.
  • OAuthStrategyService is the hook jwt.strategy requires. It re-checks the
    token row, grant, user and audience on every request: a JWT cannot be
    un-issued, so revocation has to be a database fact or "revoke" would mean
    "revoke within the hour".
  • Replay of an authorization code, or of an already-rotated refresh token,
    revokes every token on the grant (RFC 6819). We cannot tell a buggy client
    from a stolen credential, and the honest client loses one re-authorization.
  • Registration is open, as the RFC and MCP clients require. A client row grants
    nothing until a user approves it; redirect URIs are exact-match, https or
    loopback-http, no fragments.
  • Fastify parses no urlencoded bodies and OAuth mandates them, so the module
    registers a parser itself rather than patching main.ts.

mcp/

  • /mcp over streamable HTTP, stateless, one server per request so "acts as this
    user" is a property of the object rather than a discipline.
  • Nine tools mirroring the 22 core routes upstream annotated with @OAuthScope,
    which is its statement of the intended boundary.
  • Permissions are core's: same PageAccessService and CASL checks the matching
    controllers make, including that a child page needs edit on the parent while
    a root page needs Create on the space. Content writes go through the
    collaboration gateway, because page bodies are Yjs documents and a direct
    write would be overwritten by any connected editor.
  • Write tools are not registered on a read-only grant.
  • enforceMcpOauth honoured with upstream's semantics: API keys work unless it
    is on, which is what its settings panel already promises.

One bug worth recording: the first version returned oauthScopes from the
strategy. Core's guard reads user.oauth.scopes, so that would have left
user.oauth undefined, skipped the entire scope block, and let a read-only token
satisfy every @OAuthScope('write') route. Found by reading the guard, now
pinned by core-oauth-contract.spec.ts, which also asserts the prefix exclusions
that keep /mcp and the .well-known documents at the root.

98 tests pass; check-upstream.sh now gates on all of src/ee rather than only
the contract suite. Nothing here has been run against a live client — see
VERIFICATION.md, "Check these first".

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

Closes the MCP OAuth gap the upgrade surfaced, and builds the MCP server it was the authentication half of. Core ships no MCP server at all — only the route exclusions in main.ts and the workspace settings — so this is the server, not an adapter for one. The design is upstream's, read off the parts it does ship, rather than invented: the four oauth_* tables specify JWT access tokens with a jti row, hashed opaque refresh tokens and per-(user,client) grants; the consent client in apps/client/src/ee/oauth/ fixes the four endpoints, the two scopes and the error shape; main.ts fixes the URLs. oauth/ - OAuth 2.1: authorization code with mandatory PKCE (S256 only), refresh with rotation, RFC 7591 dynamic registration, RFC 8414 + RFC 9728 discovery. - OAuthStrategyService is the hook jwt.strategy requires. It re-checks the token row, grant, user and audience on every request: a JWT cannot be un-issued, so revocation has to be a database fact or "revoke" would mean "revoke within the hour". - Replay of an authorization code, or of an already-rotated refresh token, revokes every token on the grant (RFC 6819). We cannot tell a buggy client from a stolen credential, and the honest client loses one re-authorization. - Registration is open, as the RFC and MCP clients require. A client row grants nothing until a user approves it; redirect URIs are exact-match, https or loopback-http, no fragments. - Fastify parses no urlencoded bodies and OAuth mandates them, so the module registers a parser itself rather than patching main.ts. mcp/ - /mcp over streamable HTTP, stateless, one server per request so "acts as this user" is a property of the object rather than a discipline. - Nine tools mirroring the 22 core routes upstream annotated with @OAuthScope, which is its statement of the intended boundary. - Permissions are core's: same PageAccessService and CASL checks the matching controllers make, including that a child page needs edit on the parent while a root page needs Create on the space. Content writes go through the collaboration gateway, because page bodies are Yjs documents and a direct write would be overwritten by any connected editor. - Write tools are not registered on a read-only grant. - enforceMcpOauth honoured with upstream's semantics: API keys work unless it is on, which is what its settings panel already promises. One bug worth recording: the first version returned `oauthScopes` from the strategy. Core's guard reads `user.oauth.scopes`, so that would have left user.oauth undefined, skipped the entire scope block, and let a read-only token satisfy every @OAuthScope('write') route. Found by reading the guard, now pinned by core-oauth-contract.spec.ts, which also asserts the prefix exclusions that keep /mcp and the .well-known documents at the root. 98 tests pass; check-upstream.sh now gates on all of src/ee rather than only the contract suite. Nothing here has been run against a live client — see VERIFICATION.md, "Check these first". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: MCP server with an OAuth 2.1 provider
All checks were successful
Verify and publish / Upstream compatibility (push) Successful in 6m15s
Verify and publish / Build and push image (push) Has been skipped
Verify and publish / Upstream compatibility (pull_request) Successful in 6m46s
Verify and publish / Build and push image (pull_request) Has been skipped
e4da3a63e9
Closes the MCP OAuth gap the upgrade surfaced, and builds the MCP server it
was the authentication half of. Core ships no MCP server at all — only the
route exclusions in main.ts and the workspace settings — so this is the server,
not an adapter for one.

The design is upstream's, read off the parts it does ship, rather than
invented: the four oauth_* tables specify JWT access tokens with a jti row,
hashed opaque refresh tokens and per-(user,client) grants; the consent client
in apps/client/src/ee/oauth/ fixes the four endpoints, the two scopes and the
error shape; main.ts fixes the URLs.

oauth/
- OAuth 2.1: authorization code with mandatory PKCE (S256 only), refresh with
  rotation, RFC 7591 dynamic registration, RFC 8414 + RFC 9728 discovery.
- OAuthStrategyService is the hook jwt.strategy requires. It re-checks the
  token row, grant, user and audience on every request: a JWT cannot be
  un-issued, so revocation has to be a database fact or "revoke" would mean
  "revoke within the hour".
- Replay of an authorization code, or of an already-rotated refresh token,
  revokes every token on the grant (RFC 6819). We cannot tell a buggy client
  from a stolen credential, and the honest client loses one re-authorization.
- Registration is open, as the RFC and MCP clients require. A client row grants
  nothing until a user approves it; redirect URIs are exact-match, https or
  loopback-http, no fragments.
- Fastify parses no urlencoded bodies and OAuth mandates them, so the module
  registers a parser itself rather than patching main.ts.

mcp/
- /mcp over streamable HTTP, stateless, one server per request so "acts as this
  user" is a property of the object rather than a discipline.
- Nine tools mirroring the 22 core routes upstream annotated with @OAuthScope,
  which is its statement of the intended boundary.
- Permissions are core's: same PageAccessService and CASL checks the matching
  controllers make, including that a child page needs edit on the parent while
  a root page needs Create on the space. Content writes go through the
  collaboration gateway, because page bodies are Yjs documents and a direct
  write would be overwritten by any connected editor.
- Write tools are not registered on a read-only grant.
- enforceMcpOauth honoured with upstream's semantics: API keys work unless it
  is on, which is what its settings panel already promises.

One bug worth recording: the first version returned `oauthScopes` from the
strategy. Core's guard reads `user.oauth.scopes`, so that would have left
user.oauth undefined, skipped the entire scope block, and let a read-only token
satisfy every @OAuthScope('write') route. Found by reading the guard, now
pinned by core-oauth-contract.spec.ts, which also asserts the prefix exclusions
that keep /mcp and the .well-known documents at the root.

98 tests pass; check-upstream.sh now gates on all of src/ee rather than only
the contract suite. Nothing here has been run against a live client — see
VERIFICATION.md, "Check these first".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ulysia merged commit c742805b55 into main 2026-08-31 19:26:01 +02:00
ulysia deleted branch testing 2026-08-31 19:26:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ulysia/docmost-freenterprise!2
No description provided.