Skip to content

feat: MCP OAuth 2.1 - schema, types, and auth plumbing (1/4)#27069

Open
hanneskuettner wants to merge 3 commits intomainfrom
hannes/mcp-oauth-1-schema
Open

feat: MCP OAuth 2.1 - schema, types, and auth plumbing (1/4)#27069
hanneskuettner wants to merge 3 commits intomainfrom
hannes/mcp-oauth-1-schema

Conversation

@hanneskuettner
Copy link
Copy Markdown
Member

@hanneskuettner hanneskuettner commented Apr 8, 2026

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:

  1. Schema + auth plumbing (this PR) -- tables, types, env vars, token source tracking
  2. Guards on existing code -- OAuth session isolation, MCP server auth checks
  3. OAuth service + consent page -- the authorization server business logic
  4. Controller wiring + app integration -- HTTP routes, app.ts, login redirect, integration tests

Future Work (should happen before release)

  • Add Enable MCP OAuth to AI settings in the studio, disabled by default, then we could enable env by default (like MCP)
  • Add Connected Clients listing to the Studio to give admins the ability to revoke individual clients (which are automatically registered because of DCR)
  • Add CIMD (Client ID Metadata Document) support -- the MCP spec's preferred client registration mechanism (added in #27106)

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:

  • Public and confidential clients -- public clients (token_endpoint_auth_method: none) self-register via DCR
    and are identified by client_id alone, with mandatory PKCE S256. Confidential client support
    (client_secret_basic, client_secret_post) for clients like Figma Make that require a client_secret was
    added in #27107.
  • Single scope: mcp:access -- OAuth tokens are scoped exclusively to /mcp endpoints. 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.
  • Server-rendered consent -- the consent page is standalone HTML outside the Vue SPA. No JavaScript touches the
    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:

  • Database migration creating four new system tables (directus_oauth_clients, directus_oauth_codes,
    directus_oauth_consents, directus_oauth_tokens) and adding an oauth_client FK column to directus_sessions
  • System data registration so the schema introspection layer knows about these collections (field metadata YAMLs,
    collection entries, FK relations)
  • tokenSource tracking on Request -- 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 per
    RFC 6750 Section 2
  • oauth on Accountability -- getAccountabilityForToken now builds accountability.oauth: { client, scopes, aud } from the session DB (oauth_client column) and JWT claims (scope, aud). Downstream code uses this structured object to distinguish OAuth sessions and enforce scope/audience checks without re-decoding the JWT
  • Environment variables (see table below)
  • Design docs and ADRs covering token storage strategy, library choice (no library), and scope restriction rationale

Environment variables

Variable Default Description
MCP_OAUTH_ENABLED false Feature flag. Requires MCP_ENABLED=true and SERVE_APP=true.
MCP_OAUTH_AUTH_CODE_TTL '60s' How long an authorization code is valid after issuance. Kept short because the code exchange happens programmatically and immediately.
MCP_OAUTH_MAX_CLIENTS 10000 Maximum number of registered OAuth clients. Hard cap to prevent unbounded table growth from unauthenticated DCR. Set to 0 to disable.
MCP_OAUTH_CLIENT_UNUSED_TTL '3d' Cleanup TTL for clients that registered via DCR but never completed the consent flow (registration spam).
MCP_OAUTH_CLIENT_IDLE_TTL '0' Cleanup TTL for clients that were once authorized but have no active grants/sessions. '0' = disabled (default). Set to e.g. '30d' for tighter hygiene.
MCP_OAUTH_CLEANUP_SCHEDULE '*/15 * * * *' Cron schedule for the OAuth cleanup job (expired codes, idle clients, orphaned grants).
MCP_OAUTH_REQUIRE_RESOURCE false Require the RFC 8707 resource parameter on OAuth requests. When false, defaults to PUBLIC_URL/mcp if omitted (compatible with clients like Codex that don't send it). When true, strict enforcement.

Schema design

Four tables, created in FK-dependency order:

directus_oauth_clients -- registered MCP clients (RFC 7591). client_id is a string(255) primary key -- for DCR clients this is a server-assigned UUID, but the column uses string instead of uuid to support CIMD (Client ID Metadata Document) where the client_id is an HTTPS URL. This matches Keycloak's data model (VARCHAR(255)). client_id is public and not a secret since these are public clients (token_endpoint_auth_method: 'none'). redirect_uris and grant_types are stored as JSON arrays. No client_secret column -- 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_hash stores the SHA-256 hash (the raw code is never persisted). Two temporal columns serve distinct purposes: expires_at is the hard TTL (default 60s) after which the code is invalid regardless of state, while used_at tracks redemption -- NULL means unredeemed, non-NULL means 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_challenge and code_challenge_method store 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. session is the SHA-256 hash of the current refresh token and points to a row in directus_sessions. previous_session stores the hash of the last-rotated-out refresh token for reuse detection -- if a client presents previous_session instead of session, it means the current token was stolen and the grant gets revoked entirely.

directus_sessions.oauth_client -- nullable string(255) FK to directus_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 (via accountability.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 via directus_sessions.oauth_client). No application-level cleanup needed for these cases -- the scheduled cleanup job only handles expiration and orphan detection.

Potential Risks / Drawbacks

  • New system tables are added regardless of MCP_OAUTH_ENABLED -- the migration always runs. This keeps the schema
    consistent and avoids conditional migration complexity.
  • oauth_client column on directus_sessions is nullable and indexed. Existing sessions have NULL, so there's no
    data migration.

Tested Scenarios

  • Env var defaults and type-map registration (unit tests)
  • extract-token correctly sets tokenSource for all three sources (updated existing tests)
  • verifySessionJWT returns oauth_client from session record (new test file)
  • getAccountabilityForToken builds accountability.oauth from JWT claims + session DB (new tests including scope parsing and aud normalization)

Review Notes / Questions

  • The migration creates tables in FK-dependency order and tears down in reverse. All FKs use ON DELETE CASCADE for the
    user/client references.
  • tokenSource on 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_id uses string(255) instead of uuid to 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

  • Added or updated tests
  • Documentation PR created here or not required
  • OpenAPI package PR created here or not required

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 35.48387% with 80 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.92%. Comparing base (92161fb) to head (7562bf0).
⚠️ Report is 18 commits behind head on main.

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     
Flag Coverage Δ
api 50.60% <20.79%> (-1.60%) ⬇️
app 76.76% <ø> (+0.02%) ⬆️
composables 82.55% <ø> (-0.23%) ⬇️
create-directus-extension 94.11% <ø> (-0.33%) ⬇️
create-directus-project 98.30% <ø> (-0.14%) ⬇️
env 99.71% <100.00%> (+<0.01%) ⬆️
errors 96.75% <ø> (-0.32%) ⬇️
extensions 35.52% <ø> (-0.11%) ⬇️
extensions-registry 95.00% <ø> (-0.44%) ⬇️
extensions-sdk 12.02% <ø> (-2.35%) ⬇️
format-title 100.00% <ø> (ø)
memory 100.00% <ø> (ø)
pressure 76.38% <ø> (-1.25%) ⬇️
release-notes-generator 80.77% <ø> (-0.37%) ⬇️
schema-builder 81.43% <ø> (+<0.01%) ⬆️
sdk 25.42% <ø> (-0.20%) ⬇️
storage 92.00% <ø> (ø)
storage-driver-azure 72.54% <ø> (-0.79%) ⬇️
storage-driver-cloudinary 79.95% <ø> (-0.32%) ⬇️
storage-driver-gcs 65.45% <ø> (-1.51%) ⬇️
storage-driver-local 68.54% <ø> (-1.22%) ⬇️
storage-driver-s3 50.62% <ø> (-1.55%) ⬇️
storage-driver-supabase 66.36% <ø> (-0.90%) ⬇️
system-data 66.66% <100.00%> (-5.42%) ⬇️
update-check 51.68% <ø> (-3.99%) ⬇️
utils 90.23% <ø> (-0.11%) ⬇️
validation 43.25% <ø> (-0.32%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hanneskuettner hanneskuettner force-pushed the hannes/mcp-oauth-1-schema branch from 6c95c95 to 32c7a1f Compare April 8, 2026 17:12
@hanneskuettner hanneskuettner changed the title feat: MCP OAuth 2.1 -- schema, types, and auth plumbing (1/4) feat: MCP OAuth 2.1 - schema, types, and auth plumbing (1/4) Apr 8, 2026
@hanneskuettner hanneskuettner marked this pull request as ready for review April 8, 2026 19:29
@hanneskuettner hanneskuettner force-pushed the hannes/mcp-oauth-1-schema branch 8 times, most recently from b3c3b54 to 8a5613c Compare April 9, 2026 10:07
@hanneskuettner hanneskuettner force-pushed the hannes/mcp-oauth-1-schema branch from 8a5613c to 18002e3 Compare April 9, 2026 11:07
hanneskuettner and others added 2 commits April 10, 2026 11:27
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)] : [],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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,
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

making linter happy

accountability.oauth = {
client: oauth_client,
scopes: parseOAuthScope(payload.scope),
aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [String(payload.aud)] : [],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

making linter happy

* 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)
*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thoughts on reducing this to something like 24hrs or shorter, instead of 3d? Because it's just for non-authorized clients right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not sure that we actually need them, but I don't see any tests for the other ENV vars introduced.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we add a few separate indexes for stuff like

  • directus_oauth_consents.client
  • directus_oauth_clients.date_created

since we're filtering and cleaning up based on those columns?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants