From 1c610cc2a1e8123f91b1839bc399b5928c7ccc4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 21:45:35 +0000 Subject: [PATCH 1/2] feat: wildcard prefix support in OAuth redirect allowlist; allow ChatGPT ChatGPT's connector callback is per-connector (https://chatgpt.com/connector/oauth/), so an exact-match allowlist breaks whenever a connector is recreated. Support a trailing "*" in OAUTH_ALLOWED_REDIRECT_URIS entries as a scheme+host-locked prefix match, and add https://chatgpt.com/connector/oauth/* to the default allowlist so ChatGPT works out of the box. Exact matches are unchanged. Add tests covering wildcard acceptance and host-locking, and document ChatGPT setup + the wildcard syntax. https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY --- .env.example | 4 +++- app/config.py | 3 ++- app/oauth.py | 18 ++++++++++++++++-- docs/USER_GUIDE.md | 17 +++++++++++++++-- tests/test_oauth.py | 21 +++++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) 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..82d0bfc 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -128,12 +128,26 @@ def jwks() -> dict: return _jwks() +def _redirect_uri_allowed(uri: str, allowed: list[str]) -> bool: + # Exact match, or prefix match for an allowlist entry ending in "*". The + # wildcard only extends the path under a fixed scheme+host the operator + # configured (e.g. ChatGPT's per-connector https://chatgpt.com/connector/oauth/*), + # so it can't redirect codes to a different host. + for entry in allowed: + if entry.endswith("*"): + if uri.startswith(entry[:-1]): + return True + elif uri == entry: + 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..4992394 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 prefix match (e.g. `https://chatgpt.com/connector/oauth/*`, since ChatGPT uses a per-connector callback path). | | `LOG_LEVEL` | no | `INFO` | Log level. | ### Phases @@ -199,7 +200,19 @@ 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. ### REST / curl / n8n diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 3259ea7..15aa715 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -107,6 +107,27 @@ 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_dcr_registers_public_client(oauth_client): resp = oauth_client.post("/oauth/register", json={"redirect_uris": [ALLOWED_URI]}) assert resp.status_code == 201 From a733085653982995de970fb20d97cf68670581c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:08:29 +0000 Subject: [PATCH 2/2] harden redirect wildcard to enforce exact scheme+host, path-prefix only Address review: the previous wildcard used a raw startswith, so the "host-locked" claim wasn't actually enforced -- a misconfigured entry like https://chatgpt.com* would match lookalike hosts (chatgpt.com.evil.com). Parse both the pattern and candidate with urlsplit, require an exact scheme+netloc match, and prefix-match only on path. Over-broad patterns lacking a concrete host or path (*, https://*, https://chatgpt.com*) are ignored rather than honored. Add a unit test for these cases and clarify the docs on the required scheme://host/path/ wildcard format. https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY --- app/oauth.py | 27 ++++++++++++++++++++------- docs/USER_GUIDE.md | 8 +++++++- tests/test_oauth.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/app/oauth.py b/app/oauth.py index 82d0bfc..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 @@ -129,15 +130,27 @@ def jwks() -> dict: def _redirect_uri_allowed(uri: str, allowed: list[str]) -> bool: - # Exact match, or prefix match for an allowlist entry ending in "*". The - # wildcard only extends the path under a fixed scheme+host the operator - # configured (e.g. ChatGPT's per-connector https://chatgpt.com/connector/oauth/*), - # so it can't redirect codes to a different host. + # 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 entry.endswith("*"): - if uri.startswith(entry[:-1]): + if not entry.endswith("*"): + if uri == entry: return True - elif uri == entry: + 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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4992394..2cae0a1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -100,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 + chatgpt callbacks | Comma-separated allowlist for OAuth redirect URIs. An entry ending in `*` is a prefix match (e.g. `https://chatgpt.com/connector/oauth/*`, since ChatGPT uses a per-connector callback path). | +| `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 @@ -214,6 +214,12 @@ connector you create. The default allowlist already covers these via the prefix `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 Send the bearer token as an `Authorization` header. See the [REST API reference](#rest-api-reference) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 15aa715..7fc680f 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -128,6 +128,24 @@ def test_dcr_wildcard_is_host_locked(oauth_client): 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