Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions app/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from functools import lru_cache

import jwt
import structlog
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from fastapi import APIRouter, Form, HTTPException, Request
Expand All @@ -15,6 +16,7 @@
from app.config import get_settings

router = APIRouter()
_log = structlog.get_logger()

TOKEN_TTL = 24 * 3600
KEY_ID = "mem0-oauth-1"
Expand Down Expand Up @@ -89,7 +91,10 @@ def _as_metadata() -> dict:
def _protected_resource_metadata() -> dict:
base = get_settings().public_base_url.rstrip("/")
return {
"resource": f"{base}/mcp/",
# Canonical resource URI per the MCP auth spec: omit the trailing slash.
# MCP clients (Claude.ai / Cowork) canonicalize to the no-slash form and
# reject authorization if the advertised resource doesn't match.
"resource": f"{base}/mcp",
"authorization_servers": [base],
"scopes_supported": SCOPES,
"bearer_methods_supported": ["header"],
Expand Down Expand Up @@ -128,7 +133,17 @@ async def register(request: Request) -> JSONResponse:
body = await request.json()
redirect_uris = body.get("redirect_uris") or []
allowed = set(get_settings().allowed_redirect_uris_list)
if not redirect_uris or any(uri not in allowed for uri in redirect_uris):
rejected = [uri for uri in redirect_uris if uri not in allowed]
if not redirect_uris or rejected:
# Log the exact requested URIs and the active allowlist so a client whose
# callback isn't allowed (the common cause of failed Claude.ai / Cowork
# connections) can be diagnosed and added to OAUTH_ALLOWED_REDIRECT_URIS.
_log.warning(
"dcr_redirect_uri_rejected",
requested=redirect_uris,
rejected=rejected,
allowed=sorted(allowed),
)
raise HTTPException(status_code=400, detail="redirect_uri not allowed")
# Register as a public client: PKCE (S256) protects the code exchange, so
# no client_secret is issued or verified.
Expand Down
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,5 @@ status, and latency. The `Authorization` header is never logged.
| Server won't start | A required env var is missing or a provider key is absent. Check the startup logs; `app/config.py` names the missing variable. |
| Claude.ai web / Cowork can't connect | OAuth not enabled (`OAUTH_SIGNING_KEY` blank), or the client's redirect URI isn't in `OAUTH_ALLOWED_REDIRECT_URIS`. |
| "Couldn't reach the MCP server" on Claude.ai web / Cowork (but Claude Code/Desktop work) | OAuth discovery failure. Confirm `OAUTH_SIGNING_KEY` is set and `PUBLIC_BASE_URL` exactly matches the public HTTPS URL; the server must advertise the protected-resource metadata in the `/mcp/` 401 `WWW-Authenticate` header. |
| Connector fails right after consent; logs show `POST /oauth/register → 400` | The client's callback isn't in `OAUTH_ALLOWED_REDIRECT_URIS`. The server logs a `dcr_redirect_uri_rejected` warning with the exact `requested` URI and the active `allowed` list — add the requested URI to `OAUTH_ALLOWED_REDIRECT_URIS` and redeploy. Claude.ai web/desktop/mobile/Cowork use `https://claude.ai/api/mcp/auth_callback`. |
| Backup job not running | Check the backup container: `caprover logs mem0-backup`. |
4 changes: 2 additions & 2 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_protected_resource_metadata(oauth_client):
"/.well-known/oauth-protected-resource/mcp",
):
meta = oauth_client.get(path).json()
assert meta["resource"] == "https://mem0.test/mcp/"
assert meta["resource"] == "https://mem0.test/mcp"
assert meta["authorization_servers"] == ["https://mem0.test"]
assert meta["bearer_methods_supported"] == ["header"]
assert meta["scopes_supported"] == ["read", "write"]
Expand Down Expand Up @@ -404,7 +404,7 @@ async def lifespan(app):
assert meta_resp.status_code == 200, (
f"{urlparse(rm_url).path} returned {meta_resp.status_code}, not a direct 200"
)
assert meta_resp.json()["resource"].endswith("/mcp/")
assert meta_resp.json()["resource"].endswith("/mcp")
finally:
# monkeypatch handles env restoration; lru_caches must be cleared manually
# so other tests don't observe OAuth-enabled settings.
Expand Down
Loading