MCP OAuth 2.1 (OpenRMM as its own Authorization Server)
Status: implemented behind
RMM_MCP_OAUTH_ENABLED(off by default). Discovery metadata, Dynamic Client Registration, the login+consent authorization endpoint (PKCE S256), the token endpoint (code exchange + refresh with rotation), and/mcpacceptance of OAuth access tokens are all built and verified end-to-end. Enable it only with an HTTPSRMM_MCP_PUBLIC_URL.
Static bearer tokens (11-mcp) work for Claude Desktop (via the
mcp-remote bridge), but cloud MCP clients — Claude web, ChatGPT — are
built around the OAuth connector flow, not a pasted token. To let them connect
natively, the RMM becomes its own OAuth 2.1 Authorization Server (AS) and
Resource Server (RS). No separate server, no external IdP required: the OAuth
endpoints are added to the same Go app and reuse the existing users, TOTP 2FA and
per-user access scope.
Design decisions
Section titled “Design decisions”- Self-contained AS, hand-rolled and minimal (no heavy dependency like
ory/fosite) — matches the codebase (CGO-free, few deps, hand-rolled auth). - Opaque tokens (cryptographically random), stored hashed (SHA-256) —
the same pattern as
api_tokens/sessions, no JWT signing to manage. - PKCE S256 required; public clients (
token_endpoint_auth_method: none). - Dynamic Client Registration (RFC 7591) so cloud connectors self-register.
- Tokens are bound to a user → they inherit that user’s role + access
scope and require
mcp.use, so the existing permission/scope enforcement on/mcpis unchanged — only the credential’s origin differs. - Opt-in: everything is gated by
RMM_MCP_OAUTH_ENABLED(off by default). The issuer isRMM_MCP_PUBLIC_URL, which must be HTTPS when enabled and must identify the MCP door, not the public agents door. - Lives on the MCP door (
deploy/README-production.md):/.well-known/*,/oauth/*and/mcpshare one public port. - Production Compose publishes that backend listener on loopback by default.
TLS and source ACLs are enforced by the trusted edge forwarding to it; do not
expose raw app
:8080or treat UFW on loopback as the client ACL.
The flow (Claude web as the client)
Section titled “The flow (Claude web as the client)”1. GET /.well-known/oauth-protected-resource -> points at this AS GET /.well-known/oauth-authorization-server -> authorize/token/register URLs2. POST /oauth/register (DCR) -> client_id3. browser -> GET /oauth/authorize?client_id&redirect_uri&code_challenge&state user logs in to OpenRMM (password + 2FA, or single sign-on) and CONSENTS <- 302 redirect_uri?code=...&state=... (short-lived code)4. POST /oauth/token {code, code_verifier, ...} -> access_token (+ refresh_token)5. POST /mcp Authorization: Bearer <access_token> -> tools, bound to the user's role + scope; refreshed via the refresh_tokenData model (oauth_clients / oauth_codes / oauth_tokens)
Section titled “Data model (oauth_clients / oauth_codes / oauth_tokens)”oauth_clients— registered connectors (client_id, name,redirect_uris).oauth_codes— short-lived authorization codes (hashed), with the PKCEcode_challenge, bounduser_id,redirect_uri,scope; deleted on exchange.oauth_tokens— access + refresh tokens (both hashed), bound to auser_id, withexpires_at(access, 1h) andrefresh_expires_at(refresh, 30d). An expired/absent access token stops authenticating; the row is swept once both are expired.
Endpoints
Section titled “Endpoints”| Endpoint | Auth | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server |
none | RFC 8414 AS metadata (done) |
GET /.well-known/oauth-protected-resource |
none | RFC 9728 resource metadata (done) |
POST /oauth/register |
none | Dynamic Client Registration |
GET/POST /oauth/authorize |
user login + consent | authorization code |
GET /oauth/authorize/sso |
none (starts a provider sign-in) | single sign-on for the consent form |
GET /api/oauth-apps |
api_tokens.manage |
registered applications + policy |
POST /api/oauth-apps/{id}/approve |
api_tokens.manage, unscoped |
approve a pending application |
DELETE /api/oauth-apps/{id} |
api_tokens.manage, unscoped |
revoke an application and all it holds |
PUT /api/config/mcp-oauth |
api_tokens.manage, unscoped |
registration policy |
POST /oauth/token |
PKCE | code→token, refresh with rotation |
POST /mcp |
Bearer (API token or OAuth access token) | permission/scope-filtered tools |
Phased plan
Section titled “Phased plan”- ✅ Foundation — discovery metadata,
oauth_*schema,RMM_MCP_OAUTH_ENABLED. - ✅ Registration —
POST /oauth/register(validate + store clients). - ✅ Authorize + token — login+consent page, PKCE code issue,
/oauth/token(code exchange + refresh with rotation), audited (oauth.authorize/oauth.token). - ✅ Resource acceptance —
authMiddlewarealso accepts a validoauth_tokensaccess token (→ the bound user’s role/scope), so/mcpworks with the OAuth credential. - ✅ Hardening — the scheduler sweeps expired codes/tokens hourly
(
PurgeExpiredOAuth, only once both access and refresh are expired); the refresh token has its own bounded lifetime (refresh_expires_at, 30 days); and an OAuth access token is narrowed to/mcp— it returns403on the rest of the API/UI (it authenticates the MCP resource only, still gated by the bound user’s role + scope). Remaining: connect a real Claude web / ChatGPT connector to confirm interop.
Verified end-to-end (curl): register → authorize (login+consent) → code → token
(PKCE checked; wrong verifier → invalid_grant) → /mcp initialize with the
access token → refresh_token rotation.
The supported deployment remains one server replica. OAuth state is durable in PostgreSQL, but the wider agent hub, relay and scheduler are process-local; OAuth storage does not make the control plane distributed or HA.
Controlling which applications connect
Section titled “Controlling which applications connect”Applications register themselves (Dynamic Client Registration): that is how
Claude and ChatGPT connect without setup, and also how anyone can register an
application named “Claude” that sends users back to their own site. Settings ▸
API tokens ▸ MCP applications (api_tokens.manage) shows every registered
application — name, the hosts it returns to, status, active tokens, last use —
and sets the policy. Changes are instance-wide, so they also need an unscoped
principal, and they are audited (oauth.app.approve, oauth.app.revoke,
config.mcp_oauth.update).
- Revoke removes the application and everything it holds — access and refresh tokens, unredeemed codes, single sign-on grants — in one transaction. Tokens carry no foreign key to the application, so authentication also joins it: a token of a removed or unapproved application stops working at once, and a refresh token cannot mint another. The application may register again, under the policy then in force.
- Who may register: any application (the default) or only applications
on allowed hosts. With the allowlist, registration from another host is
refused, and an application registered earlier from one can no longer
authorize. Entries are host names —
claude.ai, or*.example.comfor its subdomains (not the domain itself). An empty allowlist is refused, since it would lock out every application in use. - Require approval: a newly registered application is pending. Its consent page says it is waiting for an administrator and issues nothing, and a code or refresh for it is refused, until an administrator approves it. Applications that existed before stay approved.
- Warning on the consent page: whatever the policy, when an application
returns to a host that is neither a known MCP client (
claude.ai,claude.com,chatgpt.com,chat.openai.com) nor on the allowed list, the consent page says so prominently, before anyone clicks Authorize. The list shows the same hosts as unknown.
What is recorded
Section titled “What is recorded”| Event | Where | Why |
|---|---|---|
oauth.app.register |
audit | an application appeared: name, hosts, approved or pending |
oauth.app.approve, oauth.app.revoke |
audit | who let an application in or threw it out (revoke records how many tokens died) |
config.mcp_oauth.update |
audit | who changed the policy |
oauth.authorize, oauth.token |
audit | a user authorized an application; a token was issued or refreshed |
oauth.authorize.refuse |
audit | consent refused because the user’s role lacks mcp.use |
oauth.sso_grant.reject |
audit | a single sign-on grant that did not authorize, with its reason: request_mismatch (carried to another client), unknown_or_spent (replayed), expired, user_disabled |
| registration refused by the allowlist, authorization refused (pending or unlisted application) | server log | before anyone has signed in, so the log and not the audit trail: an unauthenticated caller must not be able to fill the trail |
The console labels every audit action in every language; a test fails when the server records one it cannot name.
Signing in through single sign-on
Section titled “Signing in through single sign-on”When single sign-on is configured, the consent page offers Continue with <provider> above the password form. Accounts linked to SSO must use it: while SSO is on they have no password path (see 21-single-sign-on).
- The button opens
GET /oauth/authorize/ssowith the same request. It is validated like/oauth/authorize, and an ordinary provider sign-in starts, with the request stored beside its state (oidc_login_states.oauth_request) instead of a console destination. - The provider returns to the usual callback. Seeing a request on the state, it
resolves the account exactly as a console sign-in does — the provider’s MFA
(
RMM_OIDC_ACR_VALUES/RMM_OIDC_REQUIRED_AMR), disabled accounts, the linking rules — and, instead of opening a console session, mints a grant ticket and shows the consent page again, signed in. - Authorize posts the ticket to
/oauth/authorize, which redeems it and issues the code as it would after a password.
A ticket (oauth_sso_grants) is bound to the client, the redirect URI and the
PKCE challenge of the request it was minted for, lives five minutes, is stored
as a hash, and is deleted on first use, whatever that use is — a mismatched
attempt spends it, so it cannot be retried, replayed or carried to another
client. A user disabled between signing in and clicking Authorize is refused.
The ticket travels in the form body, never a URL, so it stays out of proxy and
access logs. The callback page is on the console origin and posts to the MCP
origin, which a top-level form submission may do without any cross-origin
permission. As on the console, the provider’s own second factor replaces the
console TOTP for these accounts.
Security notes
Section titled “Security notes”-
Consent is refused, with a message, to a role without
mcp.use— through either sign-in — rather than issuing a token/mcpwould refuse on first use. -
A consent-form sign-in that fails at the provider (refused, no code, MFA not satisfied, account disabled or not allowed) ends on a page of its own, not on the console’s login screen.
-
The consent page and its messages are sent with
X-Frame-Options: DENYandframe-ancestors 'none': a consent button under someone else’s page is how a click is stolen. The page also names the host the client redirects to. -
Codes are single-use, short-TTL, PKCE-bound; tokens are hashed at rest and expire; refresh rotates. Redirect URIs are validated against the registered set.
-
The approval guardrail is unchanged: an OAuth-authenticated client still only proposes jobs — a human approves. It inherits the bound user’s scope, so a scoped user’s connector sees only that user’s fleet.
-
/healthz(liveness) and/readyz(database ping) do not validate OAuth database operations or a real connector. Monitor token/authorize failures in application/edge logs and periodically exercise discovery plus a controlled connector flow.
