diff --git a/app/oauth.py b/app/oauth.py index 0f4793c..6c1889c 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -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 @@ -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" @@ -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"], @@ -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. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c03c426..335a198 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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`. | diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 1639bed..8df661d 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -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"] @@ -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.