diff --git a/.env.example b/.env.example index 79735fa..3894dca 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,9 @@ PUBLIC_BASE_URL=https://mem0.your-domain.com # OAuth (Phase 2, leave blank for Phase 1) OAUTH_SIGNING_KEY= -OAUTH_ALLOWED_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://cowork.com/api/mcp/auth_callback +# Comma-separated. A trailing * makes an entry a prefix match (e.g. ChatGPT +# uses a per-connector callback path under https://chatgpt.com/connector/oauth/). +OAUTH_ALLOWED_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://cowork.com/api/mcp/auth_callback,https://chatgpt.com/connector/oauth/* # Misc LOG_LEVEL=INFO diff --git a/app/config.py b/app/config.py index 48f5b9d..dad4c5d 100644 --- a/app/config.py +++ b/app/config.py @@ -38,7 +38,8 @@ class Settings(BaseSettings): oauth_signing_key: str | None = None oauth_allowed_redirect_uris: str = ( "https://claude.ai/api/mcp/auth_callback," - "https://cowork.com/api/mcp/auth_callback" + "https://cowork.com/api/mcp/auth_callback," + "https://chatgpt.com/connector/oauth/*" ) # Misc diff --git a/app/oauth.py b/app/oauth.py index 2e7fb33..ad923b9 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -4,6 +4,7 @@ import secrets import time from functools import lru_cache +from urllib.parse import urlsplit import jwt import structlog @@ -128,12 +129,38 @@ def jwks() -> dict: return _jwks() +def _redirect_uri_allowed(uri: str, allowed: list[str]) -> bool: + # Exact match, or — for an allowlist entry ending in "*" — a path-prefix match + # under an exact scheme+host. The wildcard is enforced as host-locked here + # rather than via a raw startswith: it only extends the path of a concrete + # scheme://host/path/ prefix (e.g. ChatGPT's per-connector + # https://chatgpt.com/connector/oauth/*). Over-broad patterns that lack a + # concrete host or path (e.g. "*" or "https://chatgpt.com*") are ignored, so + # a misconfigured entry can't match lookalike hosts like chatgpt.com.evil.com. + for entry in allowed: + if not entry.endswith("*"): + if uri == entry: + return True + continue + prefix = urlsplit(entry[:-1]) + if not (prefix.scheme and prefix.netloc and prefix.path): + continue + target = urlsplit(uri) + if ( + target.scheme == prefix.scheme + and target.netloc == prefix.netloc + and target.path.startswith(prefix.path) + ): + return True + return False + + @router.post("/oauth/register") 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) - rejected = [uri for uri in redirect_uris if uri not in allowed] + allowed = get_settings().allowed_redirect_uris_list + rejected = [uri for uri in redirect_uris if not _redirect_uri_allowed(uri, 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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3d5933f..2cae0a1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -15,6 +15,7 @@ connect clients to it, and use it day to day. If you want to work on the code it - [Claude Code](#claude-code) - [Claude Desktop](#claude-desktop) - [Claude.ai web / Cowork (OAuth)](#claudeai-web--cowork-oauth) + - [ChatGPT (OAuth, Developer Mode)](#chatgpt-oauth-developer-mode) - [REST / curl / n8n](#rest--curl--n8n) - [REST API reference](#rest-api-reference) - [Backups and restore](#backups-and-restore) @@ -99,7 +100,7 @@ runs, or set these in the CapRover app's **App Configs** panel for production. | `MEM0_API_KEY` | yes | — | Static bearer token protecting REST + MCP. Generate with `openssl rand -hex 32`. | | `PUBLIC_BASE_URL` | yes | — | Public URL, e.g. `https://mem0.your-domain.com`. Used in OAuth metadata. | | `OAUTH_SIGNING_KEY` | no | empty | PEM RSA private key. **Setting this enables Phase 2 OAuth.** Leave blank for Phase 1. | -| `OAUTH_ALLOWED_REDIRECT_URIS` | no | claude.ai + cowork callbacks | Comma-separated allowlist for OAuth redirect URIs. | +| `OAUTH_ALLOWED_REDIRECT_URIS` | no | claude.ai + cowork + chatgpt callbacks | Comma-separated allowlist for OAuth redirect URIs. An entry ending in `*` is a **path-prefix** match locked to an exact scheme + host — it must be a full `scheme://host/path/` prefix (e.g. `https://chatgpt.com/connector/oauth/*`). Host-only or bare wildcards (`https://chatgpt.com*`, `https://*`, `*`) are **ignored**, so a misconfigured entry can't match lookalike hosts like `chatgpt.com.evil.com`. | | `LOG_LEVEL` | no | `INFO` | Log level. | ### Phases @@ -199,7 +200,25 @@ holder of that key can mint an access token to your memories. Treat `MEM0_API_KE credential — anyone with it has full access via either the bearer header or the OAuth flow. The server also only allows redirect URIs listed in `OAUTH_ALLOWED_REDIRECT_URIS`, which defaults to -the official claude.ai and Cowork callback URLs. +the official claude.ai, Cowork, and ChatGPT callbacks. + +### ChatGPT (OAuth, Developer Mode) + +Also **Phase 2**. In ChatGPT, enable **Developer Mode**, add a custom connector pointing at +`https://mem0.your-domain.com/mcp`, and choose OAuth. On the consent screen enter your +`MEM0_API_KEY` and authorize. + +ChatGPT's OAuth callback is a **per-connector** URL of the form +`https://chatgpt.com/connector/oauth/` — the `` is unique to each +connector you create. The default allowlist already covers these via the prefix entry +`https://chatgpt.com/connector/oauth/*`, so you don't need to add the exact URL. If you've +customized `OAUTH_ALLOWED_REDIRECT_URIS`, include that wildcard entry. + +A trailing `*` is a **path-prefix** match, not a free-form glob: it is locked to the exact +scheme and host of the entry and only extends the path, so write the full +`scheme://host/path/` prefix (keep the trailing `/`). An entry without a concrete host *and* +path — `https://chatgpt.com*`, `https://*`, or a bare `*` — is ignored rather than honored, so +a typo can't accidentally allow a lookalike host such as `chatgpt.com.evil.com`. ### REST / curl / n8n diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 3259ea7..7fc680f 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -107,6 +107,45 @@ def test_dcr_rejects_disallowed_uri(oauth_client): assert resp.status_code == 400 +def test_dcr_allows_wildcard_prefix(oauth_client): + # The default allowlist includes https://chatgpt.com/connector/oauth/* so + # ChatGPT's per-connector callback (a unique path) registers without exact config. + resp = oauth_client.post( + "/oauth/register", + json={"redirect_uris": ["https://chatgpt.com/connector/oauth/eaQ3VyiNzLuI"]}, + ) + assert resp.status_code == 201 + + +def test_dcr_wildcard_is_host_locked(oauth_client): + # The trailing-* prefix must not allow a different host that merely contains + # the path, nor a lookalike host. + for bad in ( + "https://evil.com/connector/oauth/x", + "https://chatgpt.com.evil.com/connector/oauth/x", + ): + resp = oauth_client.post("/oauth/register", json={"redirect_uris": [bad]}) + assert resp.status_code == 400, bad + + +def test_redirect_uri_allowed_enforces_scheme_host_path(): + from app.oauth import _redirect_uri_allowed + + allow = ["https://chatgpt.com/connector/oauth/*"] + assert _redirect_uri_allowed("https://chatgpt.com/connector/oauth/abc", allow) + # exact host, but different scheme or path → rejected + assert not _redirect_uri_allowed("http://chatgpt.com/connector/oauth/abc", allow) + assert not _redirect_uri_allowed("https://chatgpt.com/other/abc", allow) + # lookalike host → rejected + assert not _redirect_uri_allowed("https://chatgpt.com.evil.com/connector/oauth/x", allow) + + # Over-broad patterns lacking a concrete host or path are ignored entirely, + # so a misconfigured allowlist can't open the door to arbitrary hosts. + for broad in (["*"], ["https://chatgpt.com*"], ["https://*"]): + assert not _redirect_uri_allowed("https://chatgpt.com.evil.com/x", broad) + assert not _redirect_uri_allowed("https://anything.example/x", broad) + + def test_dcr_registers_public_client(oauth_client): resp = oauth_client.post("/oauth/register", json={"redirect_uris": [ALLOWED_URI]}) assert resp.status_code == 201