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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions app/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import secrets
import time
from functools import lru_cache
from urllib.parse import urlsplit

import jwt
import structlog
Expand Down Expand Up @@ -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
Comment on lines +132 to +155
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a733085. _redirect_uri_allowed now parses both the pattern and candidate with urllib.parse.urlsplit, requires an exact scheme + netloc match, and prefix-matches only on path. Over-broad patterns lacking a concrete host or path (*, https://*, https://chatgpt.com*) are ignored rather than honored, so a misconfigured entry can no longer match chatgpt.com.evil.com. Added a unit test covering these cases.


Generated by Claude Code



@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
Expand Down
23 changes: 21 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<connector-id>` — the `<connector-id>` 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.
Comment on lines +213 to +215

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

Expand Down
39 changes: 39 additions & 0 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading