# MCP authentication The MCP server requires OAuth 2.1 with PKCE — no API keys, no static tokens. It reuses your **existing DK SaaS portal account**; there's no separate signup for MCP access. ## Discovery Standard OAuth/MCP discovery documents are published at: - `/.well-known/oauth-authorization-server` - `/.well-known/oauth-protected-resource` - `/.well-known/jwks.json` - `/.well-known/openid-configuration` Issuer and protected resource are both under `dishonkadoh.com`: ```json { "issuer": "https://dishonkadoh.com", "authorization_endpoint": "https://dishonkadoh.com/oauth/authorize", "token_endpoint": "https://dishonkadoh.com/oauth/token", "jwks_uri": "https://dishonkadoh.com/.well-known/jwks.json", "scopes_supported": ["mcp"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"] } ``` Only the `mcp` scope exists. Only `S256` PKCE is supported. Only public clients (`token_endpoint_auth_method: "none"`) are supported — there's no client-secret flow. ## 1. Register a client (optional, dynamic) `POST /api/oauth/register` implements RFC 7591 Dynamic Client Registration for public PKCE clients: ```json { "client_name": "My Agent", "redirect_uris": ["https://myapp.example.com/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` Redirect URIs must be HTTPS (localhost/127.0.0.1 exempted for local dev) and must not contain a fragment. Returns a `client_id` — no secret, since only `"none"` auth is supported. ## 2. Authorization + login handoff ![Placeholder diagram: mcp-oauth-handoff](../../_images/screenshots/mcp-oauth-handoff.svg) *Placeholder image — replace with a real diagram.* `GET /oauth/authorize` starts the standard authorization-code + PKCE dance. What makes this server's flow distinctive: **it doesn't have its own login form**. An unauthenticated request is redirected to the DK SaaS portal's own login (`app.dishonkadoh.com/login`, overridable via `OAUTH_LOGIN_URL`), tagged with an OAuth transaction ID. After the person logs into the portal, the portal calls back to `GET /oauth/handoff?oauth_transaction=...&handoff_code=...`. This handler exchanges the portal's one-time `handoff_code` for the person's identity by calling **back into the Odoo portal API**: ```text POST {OAUTH_PORTAL_API_URL}/api/oauth/handoff/consume X-OAuth-Handoff-Secret: {"code": ""} ``` — which is the same `dk_customer_portal_api` `oauth.py` controller documented under [Authentication (portal API)](../authentication.md). A successful exchange creates an OAuth session tied to the original transaction, and the person is shown a consent screen for the `mcp` scope before an authorization `code` is issued back to the requesting client. ## 3. Exchange the code for tokens ```text POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code= &redirect_uri= &client_id= &code_verifier= ``` ```json { "access_token": "", "token_type": "Bearer", "expires_in": 900, "refresh_token": "", "scope": "mcp" } ``` Authorization codes are single-use — a second redemption attempt (e.g. a retried request racing the first) gets `invalid_grant`. Refresh tokens use `grant_type=refresh_token` the same way against the same endpoint. ## Token details Access tokens are **JWTs** (`typ: "at+jwt"`, `RS256`), containing: | Claim | Value | |---|---| | `iss` | `https://dishonkadoh.com` | | `aud` | `https://dishonkadoh.com/api/mcp` | | `sub` | the portal user's ID | | `scope` | `"mcp"` | | `client_id` | the registered client | | `email`, `name` | if available from the portal account | | `exp` | 15 minutes from issuance (`OAUTH_ACCESS_TOKEN_TTL`, default 900s) | Refresh tokens are opaque, default TTL 30 days (`OAUTH_REFRESH_TOKEN_TTL`, default 2,592,000s). Both authorization codes and OAuth sessions/transactions are stored in Upstash Redis. The MCP server itself (`api/mcp/index.mjs`) verifies incoming tokens against the published JWKS, checking `iss`, `aud`, and that `scope` contains `mcp` — a validly-signed token for the wrong audience or without the `mcp` scope is still rejected with `401 invalid_token`. ## Revoking access `POST /oauth/revoke` accepts a `token` (access or refresh) and invalidates it — standard RFC 7009 token revocation.