Skip to content

OAuth 2.1 with PKCE

TDB Enterprise ships a built-in OAuth 2.1 authorization server. This lets Cursor, Claude and ChatGPT custom connectors, and any other MCP client that supports OAuth 2.1 authenticate with TDB without manual token management.

Implemented specifications:

  • RFC 9728 — OAuth 2.0 Protected Resource Metadata
  • RFC 8414 — Authorization Server Metadata
  • RFC 7591 — Dynamic Client Registration
  • PKCE S256 (required — plain is rejected)

Required environment variables

The values below are for signing in with TDB's own admin password. To sign people in through your identity provider instead, see Single sign-on and SCIM.

TDB_JWT_SECRET=<64-char hex>         # Required — signs the access tokens
TDB_ADMIN_USER=admin                 # Required — OAuth login credentials
TDB_ADMIN_PASSWORD=<password>        # Required

# Required if TDB sits behind a reverse proxy or non-localhost URL
TDB_SERVER_URL=https://tdb.yourcompany.com

If TDB_SERVER_URL is not set, TDB derives the base URL from the incoming request. Set it explicitly when running behind nginx, Caddy, or a load balancer.


Discovery endpoints

MCP clients discover the authorization server automatically. No manual configuration is needed beyond pointing the client at the TDB base URL.

Endpoint Spec Description
GET /.well-known/oauth-protected-resource RFC 9728 Resource server metadata
GET /.well-known/oauth-authorization-server RFC 8414 Authorization server metadata
# Verify discovery is working
curl http://localhost:8000/.well-known/oauth-authorization-server | python -m json.tool

Expected output:

{
  "issuer": "http://localhost:8000",
  "authorization_endpoint": "http://localhost:8000/oauth/authorize",
  "token_endpoint": "http://localhost:8000/oauth/token",
  "registration_endpoint": "http://localhost:8000/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "authorization_response_iss_parameter_supported": true
}

From enterprise 0.18.0, the authorization redirect carries iss with the issuer above (RFC 9207), so a client using several authorization servers can tell which one issued a code. A 401 from /v1/mcp names the protected-resource metadata with an absolute URL, built from TDB_SERVER_URL when it is set.


Connecting Claude Desktop

Which route works depends on where TDB runs, because Claude Desktop's custom connectors are reached from Anthropic's cloud, not from your computer.

TDB on your machine or a private network: use the TDB extension (tdb.mcpb), which connects with an API key, not OAuth. See Claude Desktop setup →. A url entry in claude_desktop_config.json does not work: that file is for local servers Claude Desktop starts itself.

TDB at a public HTTPS URL: add it as a custom connector.

  1. Set TDB_SERVER_URL to the public URL, for example https://tdb.example.com, so the discovery documents and the 401 challenge name it.
  2. In Claude Desktop, open Settings → Connectors → Add custom connector and enter https://tdb.example.com/v1/mcp.
  3. Claude registers itself by dynamic registration, or by its client metadata document if you set TDB_OAUTH_CIMD_HOSTS=claude.ai. Log in with TDB_ADMIN_USER / TDB_ADMIN_PASSWORD when the browser opens.

Tokens expire after TDB_JWT_EXPIRE_MINUTES (default 60 minutes).

Not yet tested

The custom-connector route has not been run against TDB yet. The extension route has (Windows, community 0.12.0).


Connecting Cursor

  1. Open Cursor → Settings → MCP → Add Server

  2. Set the server URL to http://localhost:8000/v1/mcp

  3. Cursor runs the OAuth 2.1 discovery and PKCE flow from your machine, so localhost works. The flow has been tested with Cursor's redirect URI but not yet inside Cursor.


Other clients

VS Code, JetBrains IDEs, Windsurf, and Cline are not confirmed to support the WWW-Authenticate OAuth discovery flow yet — connect them with a static API key via the Authorization header instead. See Connect an IDE / AI Tool → for config and per-client caveats (notably JetBrains, which has no documented way to attach a header to a remote MCP server without a stdio bridge).


Manual OAuth flow (testing)

You can walk through the full OAuth flow manually with curl:

Step 1 — Register a client (dynamic registration):

curl -X POST http://localhost:8000/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Test Client",
    "redirect_uris": ["http://localhost:9999/callback"]
  }'

Response:

{
  "client_id": "abc123...",
  "client_name": "My Test Client",
  "redirect_uris": ["http://localhost:9999/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Step 2 — Generate a PKCE code verifier and challenge:

# Generate a random code verifier (43–128 chars, URL-safe)
CODE_VERIFIER=$(python3 -c "
import secrets, base64
v = secrets.token_urlsafe(48)
print(v)
")

# Compute the S256 code challenge
CODE_CHALLENGE=$(python3 -c "
import hashlib, base64, sys
v = '$CODE_VERIFIER'
digest = hashlib.sha256(v.encode('ascii')).digest()
print(base64.urlsafe_b64encode(digest).rstrip(b'=').decode())
")

echo "Verifier: $CODE_VERIFIER"
echo "Challenge: $CODE_CHALLENGE"

Step 3 — Open the authorization URL in a browser:

http://localhost:8000/oauth/authorize
  ?response_type=code
  &client_id=abc123...
  &redirect_uri=http://localhost:9999/callback
  &code_challenge=<CODE_CHALLENGE>
  &code_challenge_method=S256
  &state=random-state-value

Log in with TDB_ADMIN_USER / TDB_ADMIN_PASSWORD. You'll be redirected to http://localhost:9999/callback?code=<auth_code>&iss=http%3A%2F%2Flocalhost%3A8000&state=random-state-value. The iss parameter (enterprise 0.18.0 and later) names the issuer; check that it equals the issuer in the authorization server metadata.

Step 4 — Exchange the code for an access token:

curl -X POST http://localhost:8000/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<auth_code>" \
  -d "client_id=abc123..." \
  -d "redirect_uri=http://localhost:9999/callback" \
  -d "code_verifier=$CODE_VERIFIER"

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600
}

Step 5 — Use the access token on MCP:

curl -X POST http://localhost:8000/v1/mcp \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "query_source",
      "arguments": {"sql": "SELECT COUNT(*) FROM data"}
    }
  }'

This runs against the first registered source, and data is the table name of a CSV source. With a database source, use a real table name and pass "source_name".


Client ID Metadata Documents

From enterprise 0.18.0, and off unless enabled. A client may use an HTTPS URL as its client_id instead of registering first. TDB fetches that URL, reads the client's name and redirect URIs from the JSON document there, and continues the flow as for a registered client. Dynamic registration stays available either way.

Enable it by listing the hosts whose documents may be fetched:

TDB_OAUTH_CIMD_HOSTS=claude.ai,client.example.com

The authorization server metadata then advertises client_id_metadata_document_supported: true.

Enabling this makes TDB fetch a URL that a client chose, from inside your network. Every fetch is guarded:

  • the host must be in TDB_OAUTH_CIMD_HOSTS;
  • the URL must be https, with a path, and no credentials, query, fragment or ./.. segments;
  • every address the host resolves to must be public. Private, loopback, link-local and cloud-metadata addresses are refused. TDB connects to the address it checked, and TLS still verifies the certificate against the hostname, so a name that re-resolves cannot redirect the fetch inward;
  • redirects are refused, and the fetch has a 5-second deadline and a 64 KiB size limit;
  • the document's client_id must equal its URL, it must describe a public client (no client_secret, token_endpoint_auth_method none), and its redirect_uris follow the rules below.

Documents are cached for 5 minutes, and refusals for 30 seconds. A refusal is logged as oauth_cimd_refused with the reason. TDB connects directly and does not use an egress proxy, so this cannot work in an air-gapped deployment.


Redirect URI restrictions

For security, TDB only allows redirect URIs that meet one of these criteria:

  • https:// — any HTTPS URI (required for production)
  • http://localhost — loopback only (required for native app flows like Cursor)

Plain http:// to a non-localhost host is rejected. This follows OAuth 2.1 BCP.


Security notes

  • PKCE is required for all authorization code flows — plain challenge method is rejected.
  • Authorization codes expire after 10 minutes and are single-use.
  • Code exchange is atomic (uses BEGIN IMMEDIATE transaction) — replay attacks are not possible.
  • All login forms use constant-time credential comparison with a 200 ms brute-force throttle.