Repository navigation
MCP OAuth
oCMS can act as the OAuth authorization server for its own /api/mcp endpoint.
It uses github.com/go-oauth2/oauth2/v4 v4.6.0 for the grant engine and the
official MCP Go SDK for bearer verification and protected-resource discovery.
OAuth is off by default. Existing API keys remain available with their CIDR,
expiry, anomaly-revocation and rate-limit policies.
OAuth bearer requests to /api/mcp do not pass through API-key
authentication. OCMS_API_ALLOWED_CIDRS and per-key source CIDRs apply only
to API keys. OAuth validates the access token, resource, grant, current
account and scopes, and applies both per-IP and per-account rate limits.
The API-key configuration requirements at startup still apply in staging
and production.
Startup refuses OCMS_OAUTH_ENABLED=true together with
OCMS_DEMO_MODE=true. On OAuth-enabled sites, clients can still revoke
their tokens when the MCP module is inactive.
The OAuth layer authenticates apps; R1.5 draft tools
separately provide gated CMS writes. No tool publishes or calls a text model
provider. The following acceptance record covers the earlier R1.1 build. On 2026-10-31,
Claude Desktop, claude.ai and ChatGPT web passed the tested OAuth, published
context, scratch and revocation/reconnect flows on Fly staging commit
f66bd7b4d5fa using Pro accounts. On 2026-11-01, both CLI OAuth lifecycles and
both bearer-key modes passed on c885da9071eb; all three GUI clients passed
fresh published-content reads on that build. Claude Code uses the dedicated
Editor account and the shared Keychain authenticator described in
docs/dev-authenticator.md. Both short-lived test keys were deactivated at the
end. External Internet IPv6/TLS and real-client-IP handling also passed on
2026-11-01 from an independent probe, including forged IPv4, IPv6 and mixed
forwarding headers. R1.1 acceptance is complete against the recorded builds.
See the current matrix in .claude/docs/PHASE5.md; SDK tests alone do not
establish client support.
Enable MCP Server in Admin → Modules, then configure and restart:
OCMS_SITE_URL=https://mcp-staging.example.com
OCMS_OAUTH_ENABLED=true
OCMS_OAUTH_CIMD_ALLOWED_HOSTS=claude.ai,chatgpt.com
OCMS_OAUTH_ALLOW_DCR=false
OCMS_OAUTH_ACCESS_TTL=3600
OCMS_OAUTH_REFRESH_TTL_DAYS=30OCMS_OAUTH_CIMD_ALLOWED_HOSTS lists the hosts that may serve client metadata
documents, separated by commas or spaces. Unset, it means claude.ai,chatgpt.com.
Set to an empty value, it means no hosts: nothing is discovered live, and only
pre-registered clients and, when enabled,
DCR can connect. After restart, the allowlist applies to cached metadata too.
Removing a host blocks fresh authorization, pending code exchange and refresh
for its unpinned clients. Already issued access tokens still follow grant
revocation and token expiry. Every entry must be a bare lowercase DNS hostname
of at least two labels (no scheme, port, path, wildcard or IP address; text is
lowercased first), or OAuth startup refuses the configuration.
The issuer must be a configured HTTPS origin, without a path, query,
fragment or credentials. Development alone permits HTTP on an IP-literal
loopback origin, such as http://127.0.0.1:8080. Request Host and forwarding
headers never choose the issuer or token audience. OAuth startup does not
activate the MCP module automatically. While the module is off, discovery,
client registration, authorization and the token endpoint answer 404;
/oauth/revoke stays available so clients can retire their tokens.
Use an editor or admin account. Authorization starts a fresh password sign-in
and follows the site's existing MFA policy, even if the browser already has
a signed-in session. Consent shows the account, client ID, client metadata
host, callback host and requested permissions. Client names are unverified
registration data. The decision has a session-bound, single-use nonce,
origin checks and a ten-minute deadline. Closing or denying it grants nothing.
Revoke an app in Account → Connected apps (/account/connected-apps).
| Endpoint | Purpose |
|---|---|
GET /.well-known/oauth-authorization-server |
OAuth server metadata |
GET /.well-known/oauth-protected-resource/api/mcp |
MCP resource and issuer metadata |
GET/POST /oauth/authorize |
Browser sign-in and explicit consent |
POST /oauth/token |
Form-encoded code exchange or rotating refresh |
POST /oauth/revoke |
Revoke a connection using a live access token or a refresh token and the matching client_id
|
POST /oauth/register |
Optional public-client registration; otherwise 404 |
Only authorization code with S256 PKCE and refresh grants are supported.
Public clients use token_endpoint_auth_method=none. Implicit, password,
client credentials, JWT assertions, client secrets and OIDC scopes are not
supported or advertised. The authorization response includes the configured
iss and echoes state; clients must validate both.
The browser callback uses a non-cacheable HTML navigation page after consent.
Its nonce-authorized script performs the GET to the validated callback, with
a normal Continue link as a fallback. This keeps form-action 'self' intact;
a cross-origin HTTP redirect directly from the consent POST is blocked by
Chrome. Success and denial both use this path. Consent remains single-use,
with fresh login/MFA and the existing ten-minute deadline.
Before a consent decision, errors for dynamically registered clients stay on
oCMS as local JSON responses. This includes access_denied after fresh sign-in
when the account lacks the requested permissions: no callback link or automatic
navigation is returned, and the pending authorization is cleared without
signing the account out. Existing DCR clients retain this policy when new
registration is disabled. CIMD and pre-registered clients keep their validated
error callbacks. Explicit consent denial, successful authorization and errors
after an explicit allow decision still use the validated callback for all
client sources.
Both authorization and token requests must contain exactly one
resource=https://mcp-staging.example.com/api/mcp. Redirects must match a
registered URI exactly. Native CIMD clients may register IP-literal loopback
or literal localhost HTTP callbacks with a variable port at authorization.
The registered hostname, path and query must still match; localhost and
127.0.0.1 are distinct registrations. The token exchange binds to the exact
redirect, including its port, used for its code. Other HTTP redirects are
refused. DCR retains its IP-literal-only HTTP registration policy. A port, where
a redirect URI has one, must be between 1 and 65535 (:0 and :99999 are
refused at registration and at authorization).
| Scope | Current behavior |
|---|---|
mcp:access |
Read published context; required to reach MCP |
pages:read |
Read unpublished pages only when the MCP drafts policy also permits it |
mcp:drafts |
Use opt-in owned AI-draft tools; no publication/deletion |
The public role has no OAuth permissions. Every MCP request resolves the current account and grant, not a cached client session. Deleting the account, removing its editor/admin role, changing its credential/session version or revoking the grant invalidates access. Grant scope reductions also take effect on existing access tokens and refreshes. API keys require a current editor/admin owner; they otherwise retain their existing policies.
Codes last at most 60 seconds and can be consumed once. Presenting a code that
was already redeemed fails with invalid_grant and, as RFC 6749 section 4.1.2
recommends, revokes the grant that code issued, access and refresh tokens
alike, durably and with a warning event. Only the client the grant belongs to,
for this resource, can trigger that; any other client's attempt only fails.
The replaying request must also prove possession: a PKCE code_verifier whose
S256 transform is the code's stored challenge and the redirect_uri the code was
issued for. Authorization codes travel in redirect URLs (history, logs,
referrers), so someone who merely saw a spent code and the public client_id
cannot disconnect the user; their request fails with invalid_grant and revokes
nothing. A concurrent second redemption of one code by the real client therefore
leaves no working token.
Codes, access and
refresh credentials contain 32 random bytes; SQLite stores SHA-256 hashes,
not their plaintext. An immediate SQLite transaction covers code consumption,
PKCE validation and token creation. Credentials are returned only after commit.
An invalid verifier or failed commit cannot consume the code or expose tokens.
Refresh rotates both credentials, invalidates the previous access token and
retains the spent refresh hash. The grant has an absolute refresh-family
expiry; rotation does not extend it. Reuse by the matching client and resource
revokes the entire family durably, including when the response is
invalid_grant. A different client cannot revoke a family by presenting its
refresh token. Keep SQLite and its normal backup policy; there is no token
encryption key to rotate. R1.7 applies bounded retention: codes expire
from storage after a 24-hour evidence window; retired grant families after
30 days. Every token/spent-refresh row of an active family is retained.
The retention policy describes atomic
batches, preview dependencies, scheduling and rollback. This maintenance
is deployed on MCP staging.
Successful refreshes count against rolling one-hour budgets for the grant and the account. The default access lifetime allows 8 per grant and 80 per account. For shorter access lifetimes, the grant limit is at least enough for one renewal per half-lifetime, rounded up. The account limit is ten times the grant limit. Recent rotations from revoked grants still count toward the account budget. Initial code redemption is separate.
When either budget is exhausted, the token endpoint returns HTTP 503
(temporarily_unavailable) with Retry-After. Keep the current refresh
token and retry after that delay. The refusal leaves the token family
intact and preserves the spent-token hashes used to detect replay.
Storage faults are retryable. A database error while checking an access
token, code or refresh token, or while revoking, is logged with its cause and
answered temporarily_unavailable (HTTP 503; the MCP endpoint answers 500),
never invalid_token or invalid_grant, so clients keep credentials that are
still valid. POST /oauth/revoke matches the client_id against the stored
grant and returns HTTP 200 after revocation. Unknown or malformed tokens,
tokens for a different client, and expired or rotated access tokens get
HTTP 200 with no effect. A spent refresh token can still revoke its family.
A storage fault that prevents revocation returns HTTP 503.
Revocation does not validate a client metadata document or read, refresh or
fetch the client registry entry. It uses the stored token and grant, so a
matching client can revoke even when its metadata has expired or is unavailable.
An unknown or non-matching client_id returns HTTP 200 with no effect, provided
the request passes the normal form and public-client checks: exactly one
non-empty client_id within the size limit, no client secret and no
Authorization header. This does not relax client validation for authorization
or token issuance.
When a loaded token and grant reveal a client mismatch, the server logs a warning with the grant and user IDs. It logs neither the submitted client ID nor credentials. The response remains HTTP 200 with no effect; unknown tokens and expired or rotated access tokens do not produce this warning.
A client-metadata host that cannot be reached (network error, timeout, rate
limiting or 5xx) is also temporarily_unavailable; any other non-200 answer
or a document that fails validation is invalid_client.
Account → Connected apps lists the user's OAuth connections and MCP-capable API
keys with scopes, creation, last use, expiry and revoke. A grant exists from
consent, but is a connection only once its code has been exchanged for a
token: grants that never issued one (an abandoned consent, a repeated consent)
are not listed, and the retention job removes them after a day. Administrators can
review/revoke across accounts in /admin/mcp. Registered metadata does not
verify app identity. The earlier /account/security grant list/revoke path
remains compatible. Revoking a connection that does not exist or belongs to
another account (a stale form, a forged ID) redirects back with an error
message and changes nothing; repeating a revoke of your own, already revoked
connection is a harmless success, and a storage failure is a logged 500. See
connected apps.
A dynamically registered client expires after one day unless it redeems an
authorization code. A provisional client without a grant can also be
reclaimed at capacity. Successful code redemption extends registration to at
least 90 days from consent. That covers the longest refresh family
(OCMS_OAUTH_REFRESH_TTL_DAYS is at most 90 days).
CIMD client IDs are HTTPS document URLs on an exact hostname allowlist, with
no credentials, query, fragment or non-443 port. Fetches use a DNS-aware SSRF
guard, no environment proxy, a five-second timeout, a 64 KiB body cap and no
redirects. The document's client_id must equal its URL; callbacks and public
client authentication are validated. Metadata is cached in SQLite for fifteen
minutes, concurrent fetches for one URL are coalesced, and registration/cache
storage has separate capacity limits for CIMD and DCR clients.
The document must be served with a JSON Content-Type (application/json or
a +json type). A failed fetch is remembered for sixty seconds (at most 256
URLs), with the same error code, so unauthenticated requests naming one URL
cannot trigger an outbound request each time; a transport fault stays
temporarily_unavailable. A shared fetch has its own five-second deadline and
is not cancelled when the caller that started it gives up.
Public registrations cannot consume CIMD admission capacity. Unused expired
clients can be removed when a source's cap is reached; existing rows can still
refresh at capacity.
DCR applies admission and capacity limits. Source addresses follow the
configured trusted-proxy rules. Rate-limited requests return HTTP 429
with Retry-After.
The default hosts cover the planned Claude and ChatGPT CIMD clients. This is
an allowlist, not proof of account compatibility. Add another exact hostname
only for a client you intend to support. DCR is a separate opt-in fallback for
public clients. It creates a random client ID and no secret. Keep DCR disabled
when it is not needed.
DCR accepts only code/refresh grant metadata. CIMD documents
must advertise authorization code support when grant_types is present;
additional advertised grant types do not enable those flows in oCMS. Both
paths still require public-client authentication and code response types.
For CIMD, an advertised token_endpoint_auth_methods_supported array is
authoritative and must include none; oCMS selects that method even when
the legacy singular preference names private_key_jwt. Without the array,
the singular method must be none. JWT assertions remain rejected. DCR
continues to require the singular none method.
Public protocol routes use the application's IP rate limiter; MCP adds its
existing request/body/concurrency guards and a per-user OAuth limit.
OCMS_OAUTH_PRE_REGISTERED_CLIENTS optionally supplies a JSON array of
owner-reviewed public client metadata. It defaults to empty, accepts at most
20 documents within 64 KiB, and applies the same client-ID host allowlist,
HTTPS identifier, callback, code-flow and none authentication checks.
With an empty allowlist the identifiers need not be on a host list: pinned
documents are never fetched. Invalid configuration refuses OAuth startup. Each exact configured identity
uses that registration instead of a live metadata fetch; unknown identities
still use normal discovery. No client gains account access from registration.
Both staging profiles pin Claude web's identity and its exact
https://claude.ai/api/mcp/auth_callback callback, reviewed on 2026-10-31.
The native CLI follow-up also prepares the distinct
https://claude.ai/oauth/claude-code-client-metadata identity with
http://localhost/callback. That identity and callback were observed from
installed Claude Code 2.1.316; its official documentation describes this
loopback form. Live metadata retrieval for both Claude identities returned
HTTP 403 from Fly with oCMS's Go transport. This explicit registration follows
CIMD pre-registration;
it preserves S256, fresh login/MFA, consent, scopes and revocation, with DCR
disabled. Update the registration only after reviewing the provider's public
identity. Registrations use the existing fifteen-minute SQLite metadata
cache; removing a pin resumes live discovery after that cache expires.
Revoke account grants separately to remove existing access immediately.
Set OCMS_OAUTH_ALLOW_DCR=true and supply a separate
OCMS_OAUTH_DCR_INITIAL_ACCESS_TOKEN to restrict who can register. Generate
the token with openssl rand -hex 32, store it as a server secret and give
it privately to clients that support initial access tokens. They send it
in the Authorization: Bearer <token> header on POST /oauth/register.
Use HTTPS outside development loopback. Malformed configured values
prevent startup.
Requests without a valid token cannot consume registration slots or admission budgets in protected mode. Clients with the token still share the same limits. The token is for registration only. Previously registered clients continue to authorize, exchange, refresh and revoke as usual. Rotating the setting and restarting changes who can register next. Discovery still advertises the registration endpoint when enabled. Keep the header out of proxy logs.
Leave DCR disabled when CIMD or pre-registered clients cover the intended integrations. If clients need DCR, configure an initial access token.
When draft authoring is enabled, AI is enabled and demo mode is off, an MCP
request without an Authorization header is challenged for mcp:access
and mcp:drafts. Clients can request both permissions in one sign-in and
consent flow. Existing grants keep their previously approved scopes.
R1.5 removes the temporary compatibility probe and its flag. Actual draft
creation/update/translation now uses the shared writer, an admin authoring
toggle and mcp:drafts; see Draft authoring.
Missing tool scope returns a Bearer insufficient_scope challenge in the
HTTP 403 response or the MCP tool error metadata. Both name mcp:access,
mcp:drafts and resource metadata. Request fresh consent and retry.
API keys retain their existing tool errors and source/expiry policies.
OCMS_AI_DISABLED=true blocks draft operations while retaining general reads.
Use a separate database, HTTPS host, fresh staging secrets and required MFA.
For authoring acceptance, use OCMS_ENV=staging and leave demo mode off. DCR
defaults off; investigate a client registration/authentication failure before
changing that policy. The deployment plan does not provision a host or alter
connected-client settings.
The R1.1 target is https://ocms-mcp-staging.fly.dev, with a separate Fly app
ocms-mcp-staging in fra. The dedicated fly.staging.toml and private
fly.staging.bootstrap.toml are accompanied by the
exact owner-run deployment procedure. The existing
fly.toml, ocms-demo and demo deploy/reset script remain unchanged.
Deploy this compatibility branch to the separate Fly app.
Use two shared vCPUs, 1 GB RAM, and one fresh 1 GB volume mounted at
/app/data, with the database and uploads under that mount. Keep
kill_timeout = 60 and the existing health endpoints. Set
min_machines_running = 1 during acceptance and keep the only Machine in the
primary region. Deploy with --ha=false and verify that exactly one Machine
and its volume exist: the flag does not remove any existing extra Machines.
Independent volumes have independent SQLite state; page writes and replay
records must use the same persistent database. See Fly configuration,
deploy options and
volume behavior.
The staging environment must include:
OCMS_ENV=staging
OCMS_SITE_URL=https://ocms-mcp-staging.fly.dev
OCMS_OAUTH_ENABLED=true
OCMS_OAUTH_CIMD_ALLOWED_HOSTS=claude.ai,chatgpt.com
OCMS_OAUTH_ALLOW_DCR=false
OCMS_OAUTH_ACCESS_TTL=3600
OCMS_OAUTH_REFRESH_TTL_DAYS=30
OCMS_AI_DISABLED=false
OCMS_DEMO_MODE=false
OCMS_REQUIRE_MFA=true
OCMS_DO_SEED=falsefly.staging.bootstrap.toml has no public service. On this first boot only,
OCMS_DO_SEED=true, OAuth and AI authoring are disabled, and development mode permits
HTTP cookies through an owner-bound loopback fly proxy tunnel. MFA remains
required. Replace seeded credentials, enable the MCP module and enroll a
dedicated editor privately. Then deploy the same image and volume with
fly.staging.toml: staging mode, secure cookies and seeding off. Use fresh
seed data and a reviewed content-only export; keep production
user/session/token databases out of staging.
The initial R1.1 probe deferred a separate editor account. Later authoring acceptance used the dedicated Editor account with required MFA. See the acceptance record for coverage by client and build.
The owner sets OCMS_SESSION_SECRET and OCMS_MFA_KEY through Fly secrets;
they must be distinct random values of at least 32 bytes. Keep them out of
TOML, repository files, shared command transcripts and logs.
Fly Proxy provides HTTPS at the app's fly.dev hostname, so R1.1 requires no
custom-domain DNS or certificate setup. Keep OCMS_SITE_URL, authorization
server issuer, protected resource and client endpoint on that same origin.
The original mcp-staging.ocms.tech hostname is deferred. If adopting it
later, review the issuer change and use the actual DNS/verification records
returned by fly certs setup; reconnect clients after the switch. See
custom-domain setup.
Before client acceptance, verify both deployment-specific controls:
-
Client IP: oCMS currently consumes
X-Forwarded-Foronly from a trusted immediate peer and scans the chain from the right. It does not consumeFly-Client-IP. Observe only the raw peer and relevant IP headers on a controlled request, configure the verified Fly peer/proxy hops inOCMS_TRUSTED_PROXIES, and confirm the resolved client reaches rate limits and auth events. Include a request with a spoofed forwarded address and a check after restart/redeploy. Avoid guessed global trust ranges. If the existing resolver cannot safely handle the observed chain, make a scoped, tested fix before proceeding. Fly request headers -
Logs: inspect owner-visible
fly logsafter a complete sign-in and MCP call. Codes, state, PKCE verifiers, bearer/refresh credentials and article content must be absent. Keep HTTP body/header debug logging disabled. Record the result without copying secrets into the evidence.
The runbook contains exact owner-run bootstrap, secrets, ingress, deploy and
verification commands. It builds from a clean commit archive to exclude
ignored local modules and working files. Branch/commit handling follows
AGENTS.md; preparing files is not commit, push or deployment approval.
Build images only from a clean, tested commit of the R1.1 branch, with the
explicit staging config/app and --ha=false supplied to fly deploy.
For configuration-only changes, validate and record the exact profile delta
and retain the reviewed image; this does not authorize a new source build or
Git mutation. Provisioning, secret handling and deployment require owner
authorization; preparation alone supplies none.
For nginx-hosted installations, use the normal reverse-proxy setup. Verify the production proxy and OAuth log redaction during the later rollout to the production host as well. Verify nginx separately from Fly acceptance.
The oCMS access logger strips OAuth query strings, and CSP violation reports
are logged without URL queries or fragments; events record only grant,
client, user and scope identifiers, the client address (forwarding headers
count only from a trusted proxy) and the request path, never its query or
fragment. Do not enable HTTP body/header debug
logging. Reverse-proxy logs need the same protection. For nginx, define a
path-only format in the http block and use it for OAuth routes:
log_format oauth_path '$remote_addr [$time_local] "$request_method $uri $server_protocol" $status';
# Inside the existing HTTPS server block:
location ^~ /oauth/ {
access_log /var/log/nginx/ocms-oauth.log oauth_path;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
}Use $uri, never $request, $request_uri, $args, $http_referer,
Authorization or request bodies for these routes. Apply equivalent redaction
to upstream load balancers and observability tools.
R1.1 OAuth acceptance is complete on the commits recorded in PHASE5, including Claude Desktop/web, ChatGPT web, both CLI OAuth/bearer lifecycles and external IPv6/TLS/forwarding verification. The retired compatibility probe established authentication/scope/revocation behavior, not real CMS draft persistence.
R1.5 covers actual CMS draft flows in Go SDK and isolated HTTP fixtures.
R1.7 acceptance records the later Desktop,
ChatGPT web and CLI authoring results by build. The November 7 staging rollout
verified discovery, S256 PKCE, mandatory MFA, MCP access and revocation of
the test grant on 9799b3e6. Existing connections were preserved. Default
preview URLs require an editor session. R1.6 adds separate, explicitly issued
share links.
Getting Started
Core Features
- Content Management
- Authoring Context
- Draft Authoring
- Desktop Authoring
- Editorial Review
- Media Library
- Taxonomy
- Menu Builder
- Forms
- Multi-Language
- SEO
- Feeds
- Video Embedding
- Agent-Ready
Administration
API & Integrations
Security
Theme Development
Module Development
Built-in Modules
- Module: Sentinel
- Module: Developer
- Module: DB Manager
- Module: Migrator
- Module: Informer
- Module: Demo Mode
- Module: Internal Analytics
- Module: External Analytics
- Module: Privacy
- Module: Embed
- Module: MCP Server
- MCP OAuth
Deployment
- Deployment
- Reverse Proxy
- Deploy: Fly.io
- MCP Staging on Fly.io
- R1.7 Release Acceptance
- R1 Release Decisions
- Deploy: Ubuntu Plesk