feat: MCP OAuth 2.1 - schema, types, and auth plumbing (1/4)#27069
feat: MCP OAuth 2.1 - schema, types, and auth plumbing (1/4)#27069hanneskuettner wants to merge 3 commits intomainfrom
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #27069 +/- ##
==========================================
- Coverage 66.34% 65.92% -0.43%
==========================================
Files 2258 2266 +8
Lines 148597 146429 -2168
Branches 11991 12119 +128
==========================================
- Hits 98589 96535 -2054
+ Misses 50008 49894 -114
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
6c95c95 to
32c7a1f
Compare
b3c3b54 to
8a5613c
Compare
8a5613c to
18002e3
Compare
CIMD (Client ID Metadata Document) uses HTTPS URLs as client identifiers. UUID columns can't hold these. Switch all client_id/client FK columns to string(255), matching Keycloak's data model. DCR-assigned UUIDs still fit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Apply special: conceal to all hash columns (code_hash, session, previous_session) so SHA-256 token hashes never surface through admin item APIs, GraphQL, search, or WebSocket collab payloads. Closes a defense-in-depth gap where a compromised admin could enumerate hashes and verify intercepted refresh tokens or auth codes against them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| accountability.oauth = { | ||
| client: oauth_client, | ||
| scopes: parseOAuthScope(payload.scope), | ||
| aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [String(payload.aud)] : [], |
There was a problem hiding this comment.
| aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [String(payload.aud)] : [], | |
| let aud: string[]; | |
| if (Array.isArray(payload.aud)) { | |
| aud = payload.aud; | |
| } else if (payload.aud) { | |
| aud = [String(payload.aud)]; | |
| } else { | |
| aud = []; | |
| } | |
| accountability.oauth = { | |
| client: oauth_client, | |
| scopes: parseOAuthScope(payload.scope), | |
| aud, | |
| }; |
| accountability.oauth = { | ||
| client: oauth_client, | ||
| scopes: parseOAuthScope(payload.scope), | ||
| aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [String(payload.aud)] : [], |
| * 3. `directus_oauth_codes` -- FKs to users + clients, unique code_hash | ||
| * 4. `directus_oauth_tokens` -- FKs to users + clients, unique(client, user), session index | ||
| * 5. `directus_sessions.oauth_client` -- nullable FK to clients (identifies OAuth sessions) | ||
| */ |
There was a problem hiding this comment.
Nit: Doesn't bother me either way - but others may have thoughts on the comments in here - since you could pick up the order by reading the actual migration.
|
|
||
| ## Key Principle | ||
|
|
||
| "Don't roll your own auth" applies to protocols and cryptographic primitives - don't invent token formats, hash |
There was a problem hiding this comment.
Love this bit. 🤣
Change the meaning of the saying to serve our needs. 🙌
| MCP_OAUTH_ENABLED: false, | ||
| MCP_OAUTH_AUTH_CODE_TTL: '60s', | ||
| MCP_OAUTH_MAX_CLIENTS: 10000, | ||
| MCP_OAUTH_CLIENT_UNUSED_TTL: '3d', |
There was a problem hiding this comment.
Thoughts on reducing this to something like 24hrs or shorter, instead of 3d? Because it's just for non-authorized clients right?
There was a problem hiding this comment.
Not sure that we actually need them, but I don't see any tests for the other ENV vars introduced.
There was a problem hiding this comment.
Should we add a few separate indexes for stuff like
directus_oauth_consents.clientdirectus_oauth_clients.date_created
since we're filtering and cleaning up based on those columns?
Scope
This is PR 1/4 adding an OAuth 2.1 authorization server to Directus so that MCP clients (like Claude Desktop) can authenticate through a standard OAuth flow instead of requiring a manually provisioned static token. The full stack:
Future Work (should happen before release)
Enable MCP OAuthto AI settings in the studio, disabled by default, then we could enable env by default (like MCP)Connected Clientslisting to the Studio to give admins the ability to revoke individual clients (which are automatically registered because of DCR)The implementation follows OAuth 2.1 with mandatory PKCE (RFC 7636), Dynamic Client Registration (RFC 7591), Resource Indicators (RFC 8707), and the discovery metadata specs (RFC 8414, RFC 9728). Design docs and ADRs are included in this PR for context.
Key design constraints:
token_endpoint_auth_method: none) self-register via DCRand are identified by
client_idalone, with mandatory PKCE S256. Confidential client support(
client_secret_basic,client_secret_post) for clients like Figma Make that require aclient_secretwasadded in #27107.
mcp:access-- OAuth tokens are scoped exclusively to/mcpendpoints. A route guard (PR 2)enforces this, and existing Directus auth flows (session refresh, logout, WebSocket) reject OAuth sessions entirely.
This keeps the blast radius tight: an OAuth token can't touch the rest of the API.
authorization code. The user authenticates via their normal Directus session, approves on a native form, and the code
goes straight to the client via 302 redirect.
This PR lays the foundation: database schema, type system changes, and low-level middleware plumbing that later PRs build on. Nothing is wired up yet -- purely additive with zero behavioral impact on existing functionality.
What's changed:
directus_oauth_clients,directus_oauth_codes,directus_oauth_consents,directus_oauth_tokens) and adding anoauth_clientFK column todirectus_sessionscollection entries, FK relations)
tokenSourcetracking onRequest-- the extract-token middleware now records where the token came from(
'cookie' | 'header' | 'query'), which later PRs use to enforce bearer-only transport for OAuth tokens perRFC 6750 Section 2
oauthon Accountability --getAccountabilityForTokennow buildsaccountability.oauth: { client, scopes, aud }from the session DB (oauth_clientcolumn) and JWT claims (scope,aud). Downstream code uses this structured object to distinguish OAuth sessions and enforce scope/audience checks without re-decoding the JWTEnvironment variables
MCP_OAUTH_ENABLEDfalseMCP_ENABLED=trueandSERVE_APP=true.MCP_OAUTH_AUTH_CODE_TTL'60s'MCP_OAUTH_MAX_CLIENTS100000to disable.MCP_OAUTH_CLIENT_UNUSED_TTL'3d'MCP_OAUTH_CLIENT_IDLE_TTL'0''0'= disabled (default). Set to e.g.'30d'for tighter hygiene.MCP_OAUTH_CLEANUP_SCHEDULE'*/15 * * * *'MCP_OAUTH_REQUIRE_RESOURCEfalseresourceparameter on OAuth requests. Whenfalse, defaults toPUBLIC_URL/mcpif omitted (compatible with clients like Codex that don't send it). Whentrue, strict enforcement.Schema design
Four tables, created in FK-dependency order:
directus_oauth_clients-- registered MCP clients (RFC 7591).client_idis astring(255)primary key -- for DCR clients this is a server-assigned UUID, but the column usesstringinstead ofuuidto support CIMD (Client ID Metadata Document) where theclient_idis an HTTPS URL. This matches Keycloak's data model (VARCHAR(255)).client_idis public and not a secret since these are public clients (token_endpoint_auth_method: 'none').redirect_urisandgrant_typesare stored as JSON arrays. Noclient_secretcolumn -- confidential client support can be added via a future migration if needed.directus_oauth_consents-- records that a user approved a specific client + redirect_uri combination. Unique on(user, client, redirect_uri). Used for potential future "remember this client" flows; currently a new consent record is created or updated on every approval.directus_oauth_codes-- short-lived authorization codes.code_hashstores the SHA-256 hash (the raw code is never persisted). Two temporal columns serve distinct purposes:expires_atis the hard TTL (default 60s) after which the code is invalid regardless of state, whileused_attracks redemption --NULLmeans unredeemed, non-NULLmeans already exchanged for tokens. This separation enables atomic burn-on-use (UPDATE WHERE used_at IS NULL) while keeping expired-but-unused codes around briefly for cleanup.code_challengeandcode_challenge_methodstore the PKCE challenge for verification at token exchange time. Both temporal columns are indexed for the cleanup schedule.directus_oauth_tokens-- active grants.UNIQUE(client, user)enforces one grant per client-user pair. This is a design choice, not an OAuth spec requirement -- RFC 6749 is silent on concurrent grants. With only one scope (mcp:access) and one resource, multiple concurrent grants to the same client would be redundant state. Easily reversible: dropping the unique constraint is a one-line migration, and the service code that replaces existing grants would just become a no-op.sessionis the SHA-256 hash of the current refresh token and points to a row indirectus_sessions.previous_sessionstores the hash of the last-rotated-out refresh token for reuse detection -- if a client presentsprevious_sessioninstead ofsession, it means the current token was stolen and the grant gets revoked entirely.directus_sessions.oauth_client-- nullablestring(255)FK todirectus_oauth_clients. When set, the session belongs to an OAuth grant rather than a regular browser/API session. This single column is how the rest of Directus distinguishes OAuth sessions from regular ones (viaaccountability.oauth).All user and client FKs use
ON DELETE CASCADE, so deleting a user automatically revokes all their OAuth grants, consents, and codes, and deleting a client tears down everything issued to it (including sessions viadirectus_sessions.oauth_client). No application-level cleanup needed for these cases -- the scheduled cleanup job only handles expiration and orphan detection.Potential Risks / Drawbacks
MCP_OAUTH_ENABLED-- the migration always runs. This keeps the schemaconsistent and avoids conditional migration complexity.
oauth_clientcolumn ondirectus_sessionsis nullable and indexed. Existing sessions haveNULL, so there's nodata migration.
Tested Scenarios
extract-tokencorrectly setstokenSourcefor all three sources (updated existing tests)verifySessionJWTreturnsoauth_clientfrom session record (new test file)getAccountabilityForTokenbuildsaccountability.oauthfrom JWT claims + session DB (new tests including scope parsing and aud normalization)Review Notes / Questions
ON DELETE CASCADEfor theuser/client references.
tokenSourceon Request is typed as the union'cookie' | 'header' | 'query' | null(null when no token is present).This is a net-new field, not a modification of existing behavior.
client_idusesstring(255)instead ofuuidto future-proof for CIMD support. DCR-assigned UUIDs fit in a varchar(255) with no behavioral change.Trying it out
This PR is schema and plumbing only -- to test the full OAuth flow end-to-end, check out PR 4 which wires everything together. See the step-by-step guide in PR 4's description.
Checklist