@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
1,185 lines • 57.8 kB
JavaScript
import { useLogger } from "../../logger/index.js";
import { getMilliseconds } from "../../utils/get-milliseconds.js";
import database_default from "../../database/index.js";
import { fetchRolesTree } from "../../permissions/lib/fetch-roles-tree.js";
import { fetchGlobalAccess } from "../../permissions/modules/fetch-global-access/fetch-global-access.js";
import { transaction } from "../../utils/transaction.js";
import { translateDatabaseError } from "../../database/errors/translate.js";
import { getSecret } from "../../utils/get-secret.js";
import { ActivityService } from "../activity.js";
import { Url } from "../../utils/url.js";
import { parseOAuthScope } from "../../utils/parse-oauth-scope.js";
import { MCP_ACCESS_SCOPE, getMcpUrls } from "../../ai/mcp/utils.js";
import { OAuthError } from "./types/error.js";
import { isDomainAllowed } from "./utils/domain.js";
import { isLoopbackHost } from "./utils/loopback.js";
import { matchRedirectUri, validateRedirectUri } from "./utils/redirect.js";
import { detectClientIdType, fetchCimdMetadata, getAllowedDomains } from "./cimd.js";
import { summarizeDcrRegistrationMetadata } from "./utils/registration-debug.js";
import { useEnv } from "@directus/env";
import { RecordNotUniqueError } from "@directus/errors";
import { isObject, parseJSON, toBoolean } from "@directus/utils";
import { Action } from "@directus/constants";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
//#region src/services/mcp-oauth/index.ts
const DEFAULT_UNUSED_CLIENT_TTL_MS = 4320 * 60 * 1e3;
const DEFAULT_CIMD_TTL_MS = 36e5;
const MAX_REDIRECT_URIS = 10;
const MAX_CLIENT_NAME_LENGTH = 200;
/** Consent JWT typ claim -- prevents token confusion with regular Directus JWTs */
const CONSENT_JWT_TYP = "directus-mcp-consent+jwt";
/** Consent JWT audience -- binds the token to the decision endpoint */
const CONSENT_JWT_AUD = "mcp-oauth-authorize-decision";
function parseStringArrayField(value, field) {
let parsed = value;
if (typeof value === "string") parsed = parseJSON(value);
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) throw new Error(`Invalid OAuth client ${field}: expected an array of strings`);
return parsed;
}
/** RFC 7636 Section 4.1: code_verifier uses unreserved characters, 43-128 length */
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
/** RFC 7636 Section 4.2: S256 code_challenge is base64url-encoded SHA-256 (always 43 chars) */
const CODE_CHALLENGE_S256_RE = /^[A-Za-z0-9_-]{43}$/;
/** RFC 6749 Section 9 token endpoint auth methods supported by this server */
const SUPPORTED_TOKEN_AUTH_METHODS = [
"none",
"client_secret_basic",
"client_secret_post"
];
/** SHA-256 hash hex format guard (64 hex chars = 256 bits) */
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
/** Client secret byte length for confidential clients (32 bytes = 256 bits) */
const CLIENT_SECRET_BYTES = 32;
/** Params checked for duplicates before redirect_uri validation (non-redirectable errors) */
const PRE_TRUST_DUPLICATE_PARAMS = ["client_id", "redirect_uri"];
/** Params checked for duplicates after redirect_uri validation (redirectable errors) */
const POST_TRUST_DUPLICATE_PARAMS = [
"response_type",
"code_challenge",
"code_challenge_method",
"scope",
"resource",
"state",
"response_mode"
];
function checkDuplicateParams(params, keys, redirectable) {
for (const key of keys) if (Array.isArray(params[key])) throw new OAuthError(400, "invalid_request", `Duplicate parameter: ${key}`, redirectable);
}
function getStringParam(params, key, redirectable) {
const value = params[key];
if (value === void 0) return;
if (typeof value !== "string") throw new OAuthError(400, "invalid_request", `${key} must be a string`, redirectable);
return value;
}
/**
* OAuth 2.1 authorization server for MCP (Model Context Protocol) access.
*
* Implements public-client profile with mandatory PKCE:
* - RFC 6749 / OAuth 2.1: authorization code grant, refresh token rotation
* - RFC 7636: PKCE (S256 only, required for all flows)
* - RFC 7591: Dynamic Client Registration (public clients, MCP_OAUTH_MAX_CLIENTS cap)
* - RFC 8707: Resource Indicators (`resource` param bound to token audience)
* - RFC 9728: Protected Resource Metadata discovery
* - RFC 8414: Authorization Server Metadata discovery
*
* Security properties: codes stored as SHA-256 hashes, PKCE verified with timing-safe
* compare, authorization codes burned atomically (UPDATE WHERE used_at IS NULL) before
* validation, refresh tokens rotated with reuse detection.
*/
var McpOAuthService = class McpOAuthService {
knex;
accountability;
schema;
constructor(options) {
this.knex = options.knex || database_default();
this.accountability = options.accountability || null;
this.schema = options.schema;
}
/**
* RFC 9728 Protected Resource Metadata.
*
* Called by `GET /.well-known/oauth-protected-resource*`. This is the first endpoint an MCP
* client hits -- it discovers which authorization server protects the `/mcp` resource.
*/
getProtectedResourceMetadata() {
const { issuerUrl, resourceUrl } = getMcpUrls();
return {
resource: resourceUrl,
authorization_servers: [issuerUrl],
scopes_supported: [MCP_ACCESS_SCOPE],
bearer_methods_supported: ["header"]
};
}
/**
* RFC 8414 Authorization Server Metadata.
*
* Called by `GET /.well-known/oauth-authorization-server*`. The client fetches this after
* discovering the AS from the protected resource metadata, then uses the endpoint URLs
* to register (DCR) and start the authorization flow.
*
* Queries settings to conditionally include `registration_endpoint` (DCR) and
* `client_id_metadata_document_supported` (CIMD).
*/
async getAuthorizationServerMetadata() {
const { issuerUrl } = getMcpUrls();
const env = useEnv();
const baseUrl = env["PUBLIC_URL"];
const settings = await this.knex("directus_settings").select("mcp_oauth_dcr_enabled", "mcp_oauth_cimd_enabled").first();
const dcrEnabled = toBoolean(env["MCP_OAUTH_DCR_ENABLED"]) && toBoolean(settings?.mcp_oauth_dcr_enabled);
const cimdEnabled = toBoolean(env["MCP_OAUTH_CIMD_ENABLED"]) && toBoolean(settings?.mcp_oauth_cimd_enabled);
const authorizationEndpoint = new Url(baseUrl).addPath("mcp-oauth", "authorize");
const tokenEndpoint = new Url(baseUrl).addPath("mcp-oauth", "token");
const revocationEndpoint = new Url(baseUrl).addPath("mcp-oauth", "revoke");
const metadata = {
issuer: issuerUrl,
authorization_endpoint: authorizationEndpoint.toString(),
token_endpoint: tokenEndpoint.toString(),
revocation_endpoint: revocationEndpoint.toString(),
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: SUPPORTED_TOKEN_AUTH_METHODS,
revocation_endpoint_auth_methods_supported: SUPPORTED_TOKEN_AUTH_METHODS,
code_challenge_methods_supported: ["S256"],
scopes_supported: [MCP_ACCESS_SCOPE],
response_modes_supported: ["query"],
authorization_response_iss_parameter_supported: true
};
if (dcrEnabled) metadata["registration_endpoint"] = new Url(baseUrl).addPath("mcp-oauth", "register").toString();
if (cimdEnabled) metadata["client_id_metadata_document_supported"] = true;
return metadata;
}
/**
* RFC 7591 Dynamic Client Registration.
*
* Called by `POST /mcp-oauth/register`. This is how MCP clients self-register before
* starting the authorization flow. No authentication required.
*
* Validates: client_name (Directus policy), redirect_uris (HTTPS or localhost, no fragments),
* grant_types (defaults to authorization_code), token_endpoint_auth_method.
* Enforces a global cap via MCP_OAUTH_MAX_CLIENTS (default 10,000). client_id is a random UUID (not a secret).
*
* @param body - Raw request body (validated internally)
* @returns DCR response with assigned client_id
* @throws {OAuthError} `invalid_client_metadata` or `invalid_redirect_uri`
*/
async registerClient(body) {
const env = useEnv();
const logger = useLogger();
if (!toBoolean(env["MCP_OAUTH_DCR_ENABLED"])) {
logger.debug({ reason: "dcr_env_disabled" }, "MCP OAuth DCR registration rejected");
throw new OAuthError(404, "not_found", "Dynamic client registration is not available");
}
if (!toBoolean((await this.knex("directus_settings").select("mcp_oauth_dcr_enabled").first())?.mcp_oauth_dcr_enabled)) {
logger.debug({ reason: "dcr_setting_disabled" }, "MCP OAuth DCR registration rejected");
throw new OAuthError(404, "not_found", "Dynamic client registration is not available");
}
function rejectRegistration(code, description) {
logger.debug({
code,
description,
registration: summarizeDcrRegistrationMetadata(input)
}, "MCP OAuth DCR validation failed");
throw new OAuthError(400, code, description);
}
if (!isObject(body)) {
logger.debug({
code: "invalid_client_metadata",
description: "Registration metadata must be an object",
registration: summarizeDcrRegistrationMetadata(body)
}, "MCP OAuth DCR validation failed");
throw new OAuthError(400, "invalid_client_metadata", "Registration metadata must be an object");
}
const input = body;
const clientName = input["client_name"];
if (typeof clientName !== "string" || clientName.length === 0) rejectRegistration("invalid_client_metadata", "client_name is required");
if (clientName.length > MAX_CLIENT_NAME_LENGTH) rejectRegistration("invalid_client_metadata", `client_name must not exceed ${MAX_CLIENT_NAME_LENGTH} characters`);
const redirectUris = input["redirect_uris"];
if (!Array.isArray(redirectUris) || redirectUris.length === 0) rejectRegistration("invalid_redirect_uri", "At least one redirect_uri is required");
if (redirectUris.length > MAX_REDIRECT_URIS) rejectRegistration("invalid_redirect_uri", `Maximum ${MAX_REDIRECT_URIS} redirect URIs allowed`);
for (const uri of redirectUris) try {
this.validateRedirectUri(uri);
} catch (err) {
if (err instanceof OAuthError) logger.debug({
code: err.code,
description: err.description,
registration: summarizeDcrRegistrationMetadata(input)
}, "MCP OAuth DCR validation failed");
throw err;
}
const grantTypes = input["grant_types"] === void 0 ? ["authorization_code"] : input["grant_types"];
if (!Array.isArray(grantTypes) || grantTypes.length === 0 || grantTypes.some((gt) => typeof gt !== "string")) rejectRegistration("invalid_client_metadata", "grant_types is required and must include authorization_code");
if (!grantTypes.includes("authorization_code")) rejectRegistration("invalid_client_metadata", "grant_types must include authorization_code");
const allowedGrantTypes = ["authorization_code", "refresh_token"];
if (grantTypes.some((gt) => !allowedGrantTypes.includes(gt))) rejectRegistration("invalid_client_metadata", "Unsupported grant type");
const authMethod = input["token_endpoint_auth_method"] ?? "client_secret_basic";
if (!SUPPORTED_TOKEN_AUTH_METHODS.includes(authMethod)) rejectRegistration("invalid_client_metadata", `Unsupported token_endpoint_auth_method: ${authMethod}`);
const responseTypes = input["response_types"];
if (responseTypes !== void 0) {
if (!Array.isArray(responseTypes) || responseTypes.length !== 1 || responseTypes[0] !== "code") rejectRegistration("invalid_client_metadata", "Only response_types [\"code\"] is supported");
}
const optionalUriFields = [
"client_uri",
"logo_uri",
"tos_uri",
"policy_uri"
];
const optionalUris = {};
for (const field of optionalUriFields) {
const value = input[field];
if (value !== void 0 && value !== null) {
if (typeof value !== "string") rejectRegistration("invalid_client_metadata", `${field} must be a string`);
try {
if (new URL(value).protocol !== "https:") rejectRegistration("invalid_client_metadata", `${field} must use HTTPS`);
} catch (err) {
if (err instanceof OAuthError) throw err;
rejectRegistration("invalid_client_metadata", `${field} is not a valid URL`);
}
optionalUris[field] = value;
} else optionalUris[field] = null;
}
const parsed = Number(env["MCP_OAUTH_MAX_CLIENTS"]);
const maxClients = Number.isNaN(parsed) ? 1e4 : parsed;
if (maxClients > 0) {
const [{ count }] = await this.knex("directus_oauth_clients").count("* as count");
if (Number(count) >= maxClients) rejectRegistration("invalid_client_metadata", "Maximum number of registered clients reached");
}
const isConfidential = authMethod !== "none";
let clientSecret;
let clientSecretHash = null;
if (isConfidential) {
clientSecret = crypto.randomBytes(CLIENT_SECRET_BYTES).toString("base64url");
clientSecretHash = this.hashToken(clientSecret);
}
const clientId = crypto.randomUUID();
const now = Math.floor(Date.now() / 1e3);
await this.knex("directus_oauth_clients").insert({
client_id: clientId,
client_name: clientName,
redirect_uris: JSON.stringify(redirectUris),
grant_types: JSON.stringify(grantTypes),
token_endpoint_auth_method: authMethod,
client_secret_hash: clientSecretHash,
registration_type: "dcr",
client_uri: optionalUris["client_uri"],
logo_uri: optionalUris["logo_uri"],
tos_uri: optionalUris["tos_uri"],
policy_uri: optionalUris["policy_uri"]
});
return {
client_id: clientId,
client_name: clientName,
redirect_uris: redirectUris,
grant_types: grantTypes,
response_types: ["code"],
token_endpoint_auth_method: authMethod,
client_id_issued_at: now,
...optionalUris["client_uri"] ? { client_uri: optionalUris["client_uri"] } : {},
...optionalUris["logo_uri"] ? { logo_uri: optionalUris["logo_uri"] } : {},
...optionalUris["tos_uri"] ? { tos_uri: optionalUris["tos_uri"] } : {},
...optionalUris["policy_uri"] ? { policy_uri: optionalUris["policy_uri"] } : {},
...isConfidential ? {
client_secret: clientSecret,
client_secret_expires_at: 0
} : {}
};
}
/**
* Validate an authorization request and produce a signed consent JWT.
*
* Called by `GET /mcp-oauth/authorize` (the consent page). The browser redirects here
* after the client constructs the authorization URL. If validation succeeds, the consent
* page renders with the signed JWT as a hidden form field.
*
* Validation order follows RFC 6749 Section 4.1.2.1 (pre-redirect vs post-redirect errors):
* 1. client_id lookup -- non-redirectable
* 2. redirect_uri exact match (RFC 6749 Section 3.1.2) -- non-redirectable
* 3. response_type (RFC 6749 Section 3.1.1) -- redirectable
* 4. PKCE code_challenge (RFC 7636 Section 4.4.1) -- redirectable
* 5. scope (RFC 6749 Section 3.3) -- redirectable
* 6. resource indicator (RFC 8707 Section 2) -- redirectable
*
* The consent JWT (HMAC-SHA256 with derived key, 5min expiry) binds the validated params
* to the user and session. It is rendered as a hidden form field on the consent page and
* verified in {@link processDecision} when the user submits the form. This serves as both
* CSRF protection and authorization parameter integrity.
*
* @param params - Authorization query/body params
* @param userId - Authenticated Directus user ID
* @param sessionHash - SHA-256 hex of the session token (binds consent to session)
* @returns Signed consent JWT and client display name
* @throws {OAuthError} Non-redirectable for client/redirect errors, redirectable after
*/
async validateAuthorization(params, userId, sessionHash) {
checkDuplicateParams(params, PRE_TRUST_DUPLICATE_PARAMS, false);
const clientId = getStringParam(params, "client_id", false);
const redirectUri = getStringParam(params, "redirect_uri", false);
if (!clientId) throw new OAuthError(400, "invalid_request", "client_id is required");
const client = await this.resolveClientWithFetch(clientId);
if (!redirectUri) throw new OAuthError(400, "invalid_request", "redirect_uri is required");
this.validateRedirectUri(redirectUri);
if (!matchRedirectUri(redirectUri, parseStringArrayField(client["redirect_uris"], "redirect_uris"))) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
checkDuplicateParams(params, POST_TRUST_DUPLICATE_PARAMS, true);
const responseType = getStringParam(params, "response_type", true);
const codeChallenge = getStringParam(params, "code_challenge", true);
const codeChallengeMethod = getStringParam(params, "code_challenge_method", true);
const scope = getStringParam(params, "scope", true);
const resource = getStringParam(params, "resource", true);
const state = getStringParam(params, "state", true);
if (!responseType) throw new OAuthError(400, "invalid_request", "response_type is required", true);
if (responseType !== "code") throw new OAuthError(400, "unsupported_response_type", "Only response_type code is supported", true);
if (!codeChallenge) throw new OAuthError(400, "invalid_request", "code_challenge is required", true);
if (!CODE_CHALLENGE_S256_RE.test(codeChallenge)) throw new OAuthError(400, "invalid_request", "code_challenge must be a valid S256 challenge", true);
if (!codeChallengeMethod || codeChallengeMethod !== "S256") throw new OAuthError(400, "invalid_request", "code_challenge_method must be S256", true);
const parsedScopes = parseOAuthScope(scope);
if (!(parsedScopes.length > 0 ? parsedScopes : [MCP_ACCESS_SCOPE]).includes(MCP_ACCESS_SCOPE)) throw new OAuthError(400, "invalid_scope", "Scope must include mcp:access", true);
const normalizedScope = MCP_ACCESS_SCOPE;
const responseMode = params["response_mode"];
if (responseMode && responseMode !== "query") throw new OAuthError(400, "invalid_request", "Only response_mode query is supported", true);
const env = useEnv();
const { resourceUrl: expectedResource } = getMcpUrls();
const requireResource = env["MCP_OAUTH_REQUIRE_RESOURCE"] === true;
const resolvedResource = resource || (!requireResource ? expectedResource : null);
if (!resolvedResource) throw new OAuthError(400, "invalid_target", "resource is required", true);
if (resolvedResource !== expectedResource) throw new OAuthError(400, "invalid_target", "resource does not match the protected resource", true);
const consentKey = this.getConsentKey();
const signedParams = jwt.sign({
typ: CONSENT_JWT_TYP,
aud: CONSENT_JWT_AUD,
sub: userId,
session_hash: sessionHash,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
scope: normalizedScope,
resource: resolvedResource,
state
}, consentKey, {
expiresIn: "5m",
algorithm: "HS256"
});
const registrationType = client["registration_type"] ?? "dcr";
let clientDomain;
if (registrationType === "cimd") try {
clientDomain = new URL(clientId).hostname;
} catch {}
return {
signed_params: signedParams,
client_name: client["client_name"],
already_consented: false,
redirect_uri: redirectUri,
scope: normalizedScope,
registration_type: registrationType,
client_domain: clientDomain
};
}
/**
* Process the user's consent decision (approve/deny).
*
* Called by `POST /mcp-oauth/authorize/decision` when the user submits the consent form.
* Verifies the consent JWT produced by {@link validateAuthorization} (audience, typ, sub,
* session binding), then either redirects with `error=access_denied` (RFC 6749 Section 4.1.2.1)
* or generates an authorization code (32 random bytes, stored as SHA-256 hash) and upserts
* a consent record. The redirect includes `iss` per RFC 9207 Section 2.
*
* Security checks before code issuance:
* - Consent JWT signature + expiry (HMAC-SHA256, 5min TTL)
* - `typ` claim prevents token confusion with other JWTs
* - `sub` must match the authenticated user (prevents cross-user replay)
* - `session_hash` must match the current session (prevents cross-session replay)
* - Client re-validated against DB (handles client deletion between consent and decision)
*
* @param params - Contains the signed consent JWT and approval boolean
* @param userId - Authenticated user (must match JWT `sub`)
* @param sessionToken - Raw session token (hashed and compared to JWT `session_hash`)
* @returns Redirect URL with `code` (approval) or `error` (denial) query params
* @throws {OAuthError} If consent JWT is invalid, expired, or session-mismatched
*/
async processDecision(params, userId, sessionToken) {
const { signed_params, approved } = params;
const consentKey = this.getConsentKey();
let claims;
try {
claims = jwt.verify(signed_params, consentKey, {
algorithms: ["HS256"],
audience: CONSENT_JWT_AUD
});
} catch {
throw new OAuthError(400, "invalid_request", "Invalid or expired consent token");
}
if (claims["typ"] !== CONSENT_JWT_TYP) throw new OAuthError(400, "invalid_request", "Invalid consent token type");
if (claims["sub"] !== userId) throw new OAuthError(400, "invalid_request", "Consent token user mismatch");
const currentSessionHash = this.hashToken(sessionToken);
if (claims["session_hash"] !== currentSessionHash) throw new OAuthError(400, "invalid_request", "Session binding mismatch");
const redirectUri = claims["redirect_uri"];
const state = claims["state"];
const { issuerUrl } = getMcpUrls();
const clientId = claims["client_id"];
this.validateRedirectUri(redirectUri);
const client = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
if (!client) throw new OAuthError(400, "invalid_request", "Client no longer exists");
if (!matchRedirectUri(redirectUri, parseStringArrayField(client["redirect_uris"], "redirect_uris"))) throw new OAuthError(400, "invalid_request", "redirect_uri no longer registered for this client");
if (String(approved) !== "true") return this.buildRedirectUrl(redirectUri, { error: "access_denied" }, state, issuerUrl);
const rawCode = crypto.randomBytes(CLIENT_SECRET_BYTES).toString("hex");
const codeHash = this.hashToken(rawCode);
const env = useEnv();
const codeExpiry = new Date(Date.now() + getMilliseconds(env["MCP_OAUTH_AUTH_CODE_TTL"], 0));
await transaction(this.knex, async (trx) => {
await trx("directus_oauth_codes").insert({
id: crypto.randomUUID(),
code_hash: codeHash,
client: clientId,
user: userId,
redirect_uri: redirectUri,
resource: claims["resource"],
code_challenge: claims["code_challenge"],
code_challenge_method: claims["code_challenge_method"],
scope: claims["scope"],
expires_at: codeExpiry
});
const existing = await trx("directus_oauth_consents").where({
user: userId,
client: clientId,
redirect_uri: redirectUri
}).first();
const now = /* @__PURE__ */ new Date();
if (existing) await trx("directus_oauth_consents").where("id", existing["id"]).update({ date_updated: now });
else await trx("directus_oauth_consents").insert({
id: crypto.randomUUID(),
user: userId,
client: clientId,
redirect_uri: redirectUri,
scope: claims["scope"],
date_created: now,
date_updated: now
});
});
return this.buildRedirectUrl(redirectUri, { code: rawCode }, state, issuerUrl);
}
/**
* Exchange an authorization code for tokens (RFC 6749 Section 4.1.3).
*
* Called by the `POST /mcp-oauth/token` controller when `grant_type=authorization_code`.
* The client sends the code it received from the authorization redirect (produced by
* {@link processDecision}) along with its PKCE code_verifier.
*
* Pre-transaction:
* 1. Param validation (RFC 6749 Section 4.1.3: grant_type, code, redirect_uri, client_id)
* 2. code_verifier format check (RFC 7636 Section 4.1: unreserved chars, 43-128 length)
* 3. Code lookup by SHA-256 hash (raw code never stored)
*
* Inside transaction:
* 4. Atomic burn: `UPDATE WHERE used_at IS NULL` (RFC 6749 Section 10.5: single-use codes)
* 5. Post-burn validations (expiry, client_id, redirect_uri, resource)
* Failures roll back the burn, restoring the code for retry
* 6. PKCE S256 verification via timing-safe compare (RFC 7636 Section 4.6)
* 7. User status check (must still be active)
* 8. Session + grant creation, replacing any existing grant for the same (client, user)
*
* Post-transaction:
* 9. JWT signed with scope=mcp:access, aud=resource URL (RFC 8707 Section 2)
* 10. refresh_token = raw session token (client stores it, server only keeps hash)
*
* @param params - Token request body (authorization_code grant)
* @param context - IP and user-agent for session/activity records
* @returns Token response (RFC 6749 Section 5.1) with access_token, optional refresh_token
* @throws {OAuthError} `invalid_grant` for code issues, `invalid_target` for resource mismatch
*/
async exchangeCode(params, context) {
const { nanoid } = await import("nanoid");
const env = useEnv();
const logger = useLogger();
const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
params.client_id = resolvedClientId;
const preAuthClient = await this.resolveClientFromDb(resolvedClientId);
if (!preAuthClient) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
this.authenticateClient(preAuthClient, params, basicAuth);
const tokenEndpointAuthMethod = preAuthClient["token_endpoint_auth_method"];
const isAuthenticatedConfidentialClient = tokenEndpointAuthMethod === "client_secret_basic" || tokenEndpointAuthMethod === "client_secret_post";
if (!params.grant_type) throw new OAuthError(400, "invalid_request", "grant_type is required");
if (params.grant_type !== "authorization_code") throw new OAuthError(400, "unsupported_grant_type", "Only authorization_code grant is supported");
if (!params.code) throw new OAuthError(400, "invalid_request", "code is required");
if (!params.redirect_uri) throw new OAuthError(400, "invalid_request", "redirect_uri is required");
if (!params.code_verifier) throw new OAuthError(400, "invalid_request", "code_verifier is required");
if (!CODE_VERIFIER_RE.test(params.code_verifier)) throw new OAuthError(400, "invalid_request", "Invalid code_verifier format");
const codeHash = this.hashToken(params.code);
const codeRecord = await this.knex("directus_oauth_codes").where({ code_hash: codeHash }).first();
if (!codeRecord) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
function rejectCode(logFields, logMessage) {
logger.warn({
code_hash: codeHash,
...logFields
}, logMessage);
throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
}
const sessionToken = nanoid(64);
const sessionHash = this.hashToken(sessionToken);
const refreshTtl = getMilliseconds(env["REFRESH_TOKEN_TTL"], 0);
const accessTtl = getMilliseconds(env["ACCESS_TOKEN_TTL"], 0);
const sessionExpiry = new Date(Date.now() + refreshTtl);
const grantId = crypto.randomUUID();
const exchangeResult = await transaction(this.knex, async (trx) => {
if (await trx("directus_oauth_codes").where({ code_hash: codeHash }).whereNull("used_at").update({ used_at: /* @__PURE__ */ new Date() }) === 0) {
logger.warn({ code_hash: codeHash }, "Authorization code already used");
if (isAuthenticatedConfidentialClient) await this.revokeGrantByCodeHash(trx, codeHash, resolvedClientId);
return { replayed: true };
}
if (new Date(codeRecord["expires_at"]) < /* @__PURE__ */ new Date()) rejectCode({}, "Authorization code expired");
if (codeRecord["client"] !== params.client_id) rejectCode({
expected: codeRecord["client"],
got: params.client_id
}, "client_id mismatch");
if (codeRecord["redirect_uri"] !== params.redirect_uri) rejectCode({}, "redirect_uri mismatch");
const resolvedExchangeResource = params.resource || (!env["MCP_OAUTH_REQUIRE_RESOURCE"] ? codeRecord["resource"] : null);
if (codeRecord["resource"] !== resolvedExchangeResource) {
logger.warn({
code_hash: codeHash,
expected: codeRecord["resource"],
got: params.resource
}, "resource mismatch");
throw new OAuthError(400, "invalid_target", "Authorization code is invalid or has expired");
}
const computedChallenge = this.hashToken(params.code_verifier, "base64url");
const storedChallenge = codeRecord["code_challenge"];
if (computedChallenge.length !== storedChallenge.length || !crypto.timingSafeEqual(Buffer.from(computedChallenge), Buffer.from(storedChallenge))) rejectCode({}, "PKCE verification failed");
const client = await this.resolveClientFromDb(params.client_id, trx);
if (!client) rejectCode({ client_id: params.client_id }, "Unknown client during code exchange");
const txClientGrantTypes = parseStringArrayField(client["grant_types"], "grant_types");
const txClientName = client["client_name"];
const txUserId = codeRecord["user"];
const { email: txUserEmail, role: txUserRole } = await this.requireActiveUser(txUserId, trx);
const txScope = codeRecord["scope"] || MCP_ACCESS_SCOPE;
const txResource = codeRecord["resource"];
const existingGrant = await trx("directus_oauth_tokens").where({
client: params.client_id,
user: txUserId
}).first();
if (existingGrant) {
await trx("directus_oauth_tokens").where({
client: params.client_id,
user: txUserId
}).delete();
await trx("directus_sessions").where("token", existingGrant.session).delete();
}
await trx("directus_sessions").insert({
token: sessionHash,
user: txUserId,
expires: sessionExpiry,
ip: context.ip,
user_agent: context.userAgent,
oauth_client: params.client_id
});
await trx("directus_oauth_tokens").insert({
id: grantId,
client: params.client_id,
user: txUserId,
session: sessionHash,
resource: txResource,
code_hash: codeHash,
scope: txScope,
expires_at: sessionExpiry,
date_created: /* @__PURE__ */ new Date()
});
return {
replayed: false,
clientGrantTypes: txClientGrantTypes,
clientName: txClientName,
userEmail: txUserEmail,
userRole: txUserRole,
userId: txUserId,
scope: txScope,
resource: txResource
};
});
if (exchangeResult.replayed) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
const { clientGrantTypes, clientName, userEmail, userRole, userId, scope, resource } = exchangeResult;
const accessToken = await this.issueMcpAccessToken({
userId,
role: userRole,
sessionHash,
resource,
accessTtl,
ip: context.ip
});
await this.recordOAuthActivity({
action: Action.LOGIN,
userId,
grantId,
comment: `OAuth grant issued for client ${clientName} (${scope}) to ${userEmail}`,
ip: context.ip,
userAgent: context.userAgent
});
logger.info({
client_id: params.client_id,
scope,
resource,
user_id: userId,
action: "oauth_token_issued",
ip: context.ip
}, "OAuth token issued");
const includeRefreshToken = clientGrantTypes.includes("refresh_token");
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: Math.floor(accessTtl / 1e3),
...includeRefreshToken ? { refresh_token: sessionToken } : {},
scope: MCP_ACCESS_SCOPE
};
}
/**
* Refresh an access token with session rotation (RFC 6749 Section 6).
*
* Called by `POST /mcp-oauth/token` when `grant_type=refresh_token`. The client sends
* the refresh_token it received from {@link exchangeCode} or a previous refresh.
*
* Session rotation: hash incoming token, look up grant by `session`, atomically
* `UPDATE WHERE session=old_hash` to new hash. If 0 rows updated, check `previous_session`
* for reuse detection -- if found, revoke the entire grant (both grant and session deleted).
*
* @param params - Refresh token request body
* @param context - IP and user-agent for the new session
* @returns New token response with rotated refresh_token
* @throws {OAuthError} `invalid_grant` on reuse or expiry, `invalid_target` on resource mismatch
* @see exchangeCode for initial token issuance
*/
async refreshToken(params, context) {
const { nanoid } = await import("nanoid");
const env = useEnv();
const logger = useLogger();
const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
params.client_id = resolvedClientId;
if (params.grant_type !== "refresh_token") throw new OAuthError(400, "unsupported_grant_type", "grant_type must be refresh_token");
if (!params.refresh_token) throw new OAuthError(400, "invalid_request", "refresh_token is required");
if (env["MCP_OAUTH_REQUIRE_RESOURCE"] === true && !params.resource) throw new OAuthError(400, "invalid_target", "resource is required");
const client = await this.resolveClientFromDb(params.client_id);
if (!client) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
this.authenticateClient(client, params, basicAuth);
if (!parseStringArrayField(client["grant_types"], "grant_types").includes("refresh_token")) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
const oldSessionHash = this.hashToken(params.refresh_token);
const grant = await this.knex("directus_oauth_tokens").where("session", oldSessionHash).first();
if (!grant) {
await transaction(this.knex, async (trx) => {
await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
});
throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
}
if (grant["client"] !== params.client_id) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
if (params.scope) {
const requestedScopes = parseOAuthScope(params.scope);
const grantedScopes = parseOAuthScope(grant["scope"] || MCP_ACCESS_SCOPE);
if (!requestedScopes.includes(MCP_ACCESS_SCOPE)) throw new OAuthError(400, "invalid_scope", "Scope must include mcp:access");
if (requestedScopes.some((scope$1) => !grantedScopes.includes(scope$1))) throw new OAuthError(400, "invalid_scope", "Scope must not include scopes outside the original grant");
}
const resolvedRefreshResource = params.resource || (!env["MCP_OAUTH_REQUIRE_RESOURCE"] ? grant["resource"] : null);
if (grant["resource"] !== resolvedRefreshResource) throw new OAuthError(400, "invalid_target", "resource mismatch");
if (new Date(grant["expires_at"]) < /* @__PURE__ */ new Date()) throw new OAuthError(400, "invalid_grant", "Refresh token expired");
const userId = grant["user"];
const { email, role } = await this.requireActiveUser(userId, this.knex);
const newSessionToken = nanoid(64);
const newSessionHash = this.hashToken(newSessionToken);
const refreshTtl = getMilliseconds(env["REFRESH_TOKEN_TTL"], 0);
const accessTtl = getMilliseconds(env["ACCESS_TOKEN_TTL"], 0);
const newExpiry = new Date(Date.now() + refreshTtl);
const resource = grant["resource"];
const scope = grant["scope"] || MCP_ACCESS_SCOPE;
const grantId = grant["id"];
const clientName = client["client_name"];
if (!await transaction(this.knex, async (trx) => {
if (await trx("directus_sessions").where({
token: oldSessionHash,
user: userId,
oauth_client: grant["client"]
}).delete() === 0) {
if (await trx("directus_oauth_tokens").where({
id: grantId,
session: oldSessionHash,
client: params.client_id
}).first("id")) await trx("directus_oauth_tokens").where({
id: grantId,
session: oldSessionHash,
client: params.client_id
}).delete();
else await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
return false;
}
if (await trx("directus_oauth_tokens").where({
id: grantId,
session: oldSessionHash,
client: params.client_id
}).update({
session: newSessionHash,
previous_session: oldSessionHash,
expires_at: newExpiry
}) === 0) {
await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
return false;
}
await trx("directus_sessions").insert({
token: newSessionHash,
user: userId,
expires: newExpiry,
ip: context.ip,
user_agent: context.userAgent,
oauth_client: grant["client"]
});
return true;
})) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
const accessToken = await this.issueMcpAccessToken({
userId,
role,
sessionHash: newSessionHash,
resource,
accessTtl,
ip: context.ip
});
await this.recordOAuthActivity({
action: Action.UPDATE,
userId,
grantId,
comment: `OAuth token refreshed for client ${clientName} (${scope}) by ${email}`,
ip: context.ip,
userAgent: context.userAgent
});
logger.info({
client_id: params.client_id,
scope,
resource,
user_id: userId,
action: "oauth_token_refreshed",
ip: context.ip
}, "OAuth token refreshed");
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: Math.floor(accessTtl / 1e3),
refresh_token: newSessionToken,
scope: MCP_ACCESS_SCOPE
};
}
/**
* Revoke a refresh token (RFC 7009 Section 2). Idempotent for unknown tokens.
*
* Called by `POST /mcp-oauth/revoke`. The client sends its refresh_token to end the session.
*
* Client authentication is enforced first (resolveClientId + authenticateClient).
* Unknown client_id or failed secret verification rejects with 401 invalid_client.
* Per RFC 7009 Section 2.2, unknown/mismatched tokens return silent 200.
*
* @param params - Token, client_id, and optional authorization_header
* @throws {OAuthError} `invalid_client` if client unknown or auth fails
* @throws {OAuthError} `invalid_request` if token is missing
*/
async revokeToken(params) {
const logger = useLogger();
const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
params.client_id = resolvedClientId;
const client = await this.resolveClientFromDb(resolvedClientId);
if (!client) throw new OAuthError(401, "invalid_client", "Client authentication failed");
this.authenticateClient(client, params, basicAuth);
if (!params.token) throw new OAuthError(400, "invalid_request", "token is required");
const tokenHash = this.hashToken(params.token);
const grant = await this.knex("directus_oauth_tokens").where("session", tokenHash).first();
if (!grant || grant["client"] !== params.client_id) return;
const grantId = grant["id"];
const userId = grant["user"];
const clientName = client["client_name"];
await transaction(this.knex, async (trx) => {
await trx("directus_oauth_tokens").where("id", grantId).delete();
await trx("directus_sessions").where("token", tokenHash).delete();
});
const userEmail = (await this.knex("directus_users").where("id", userId).select("email").first())?.email ?? "unknown";
await this.recordOAuthActivity({
action: Action.LOGOUT,
userId,
grantId,
comment: `OAuth token revoked for client ${clientName} by ${userEmail}`,
ip: "system",
userAgent: "system"
});
logger.info({
client_id: params.client_id,
user_id: userId,
action: "oauth_token_revoked"
}, "OAuth token revoked");
}
/**
* Periodic cleanup of expired/orphaned OAuth data.
*
* Called by the `oauth-cleanup` scheduled job (cron: `MCP_OAUTH_CLEANUP_SCHEDULE`).
*
* Steps:
* 1. Expired unused codes
* 2. Used codes older than 1 hour (kept briefly for replay detection logging)
* 3. Expired grants + their sessions
* 4. Orphaned grants (session no longer in directus_sessions)
* 5. Stale clients in two tiers:
* a) Never-authorized (no consents, no sessions/grants, older than MCP_OAUTH_CLIENT_UNUSED_TTL)
* b) Idle authorized (has consents but no sessions/grants, older than MCP_OAUTH_CLIENT_IDLE_TTL; disabled when '0')
*/
async cleanup() {
const env = useEnv();
const now = /* @__PURE__ */ new Date();
const oneHourAgo = /* @__PURE__ */ new Date(now.getTime() - 3600 * 1e3);
await this.knex("directus_oauth_codes").where("expires_at", "<", now).whereNull("used_at").delete();
await this.knex("directus_oauth_codes").whereNotNull("used_at").andWhere("used_at", "<", oneHourAgo).delete();
const expiredGrants = await this.knex("directus_oauth_tokens").where("expires_at", "<", now).select("id", "session");
if (expiredGrants.length > 0) {
const sessionHashes = expiredGrants.map((g) => g.session);
await this.knex("directus_sessions").whereIn("token", sessionHashes).delete();
await this.knex("directus_oauth_tokens").whereIn("id", expiredGrants.map((g) => g.id)).delete();
}
const orphanedGrants = await this.knex("directus_oauth_tokens").leftJoin("directus_sessions", function() {
this.on("directus_oauth_tokens.session", "=", "directus_sessions.token");
}).whereNull("directus_sessions.token").select("directus_oauth_tokens.id");
if (orphanedGrants.length > 0) await this.knex("directus_oauth_tokens").whereIn("id", orphanedGrants.map((g) => g.id)).delete();
const unusedTtl = getMilliseconds(env["MCP_OAUTH_CLIENT_UNUSED_TTL"], DEFAULT_UNUSED_CLIENT_TTL_MS);
const unusedCutoff = new Date(now.getTime() - unusedTtl);
const neverAuthorizedClients = await this.knex("directus_oauth_clients").leftJoin("directus_oauth_consents", "directus_oauth_clients.client_id", "directus_oauth_consents.client").leftJoin("directus_sessions", "directus_oauth_clients.client_id", "directus_sessions.oauth_client").leftJoin("directus_oauth_tokens", "directus_oauth_clients.client_id", "directus_oauth_tokens.client").whereNull("directus_oauth_consents.id").whereNull("directus_sessions.token").whereNull("directus_oauth_tokens.id").where("directus_oauth_clients.date_created", "<", unusedCutoff).select("directus_oauth_clients.client_id");
if (neverAuthorizedClients.length > 0) await this.knex("directus_oauth_clients").whereIn("client_id", neverAuthorizedClients.map((c) => c.client_id)).delete();
const idleTtl = getMilliseconds(env["MCP_OAUTH_CLIENT_IDLE_TTL"], 0);
if (idleTtl > 0) {
const idleCutoff = new Date(now.getTime() - idleTtl);
const idleAuthorizedClients = await this.knex("directus_oauth_clients").leftJoin("directus_sessions", "directus_oauth_clients.client_id", "directus_sessions.oauth_client").leftJoin("directus_oauth_tokens", "directus_oauth_clients.client_id", "directus_oauth_tokens.client").whereNull("directus_sessions.token").whereNull("directus_oauth_tokens.id").where("directus_oauth_clients.date_created", "<", idleCutoff).whereNotIn("directus_oauth_clients.client_id", neverAuthorizedClients.map((c) => c.client_id)).select("directus_oauth_clients.client_id");
if (idleAuthorizedClients.length > 0) await this.knex("directus_oauth_clients").whereIn("client_id", idleAuthorizedClients.map((c) => c.client_id)).delete();
}
}
/**
* Resolve a client by ID, handling both DCR (UUID) and CIMD (URL) client IDs.
* Used ONLY by `validateAuthorization` -- the authorization entry point where
* CIMD clients are fetched/cached on first contact.
*/
async resolveClientWithFetch(clientId) {
const logger = useLogger();
const type = detectClientIdType(clientId);
if (type === null) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
if (type === "dcr") {
const row = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
if (!row) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
return row;
}
if (!toBoolean(useEnv()["MCP_OAUTH_CIMD_ENABLED"])) throw new OAuthError(400, "invalid_client", "CIMD client registration is disabled");
if (!toBoolean((await this.knex("directus_settings").select("mcp_oauth_cimd_enabled").first())?.mcp_oauth_cimd_enabled)) throw new OAuthError(400, "invalid_client", "CIMD client registration is disabled");
const allowedDomains = getAllowedDomains();
if (allowedDomains.length > 0) {
const hostname = new URL(clientId).hostname;
if (!isDomainAllowed(hostname, allowedDomains)) {
logger.debug({ client_id: clientId }, "CIMD client_id domain not in allowlist");
throw new OAuthError(400, "invalid_client", "Client not allowed");
}
}
const existing = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
if (existing) {
if ((existing["metadata_expires_at"] ? new Date(existing["metadata_expires_at"]).getTime() : 0) > Date.now()) return existing;
return await this.refreshCimdClient(existing);
}
return await this.insertCimdClient(clientId);
}
/**
* Simple DB lookup for a client. Used by `exchangeCode` (with trx!), `refreshToken`,
* and `revokeToken`. Does NOT gate on CIMD disabled (drain-naturally pattern).
*/
async resolveClientFromDb(clientId, db = this.knex) {
return db("directus_oauth_clients").where("client_id", clientId).first();
}
/** Base64 character set: A-Z, a-z, 0-9, +, /, = (padding) */
static BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
/**
* Parse an Authorization: Basic header. Returns { clientId, clientSecret }
* or null if the header is absent or not Basic scheme.
* Throws OAuthError on malformed Basic header.
*/
parseBasicAuth(header) {
if (!header) return null;
if (header.length < 6 || header.slice(0, 6).toLowerCase() !== "basic ") return null;
const encoded = header.slice(6).trim();
if (!McpOAuthService.BASE64_RE.test(encoded)) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: invalid base64");
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
if (decoded.includes("\0")) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: contains null bytes");
const colonIndex = decoded.indexOf(":");
if (colonIndex === -1) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: missing colon separator");
try {
const clientId = decodeURIComponent(decoded.slice(0, colonIndex).replace(/\+/g, " "));
const clientSecret = decodeURIComponent(decoded.slice(colonIndex + 1).replace(/\+/g, " "));
if (!clientId) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: client_id is required");
if (clientId.includes("\0") || clientSecret.includes("\0")) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: contains null bytes");
return {
clientId,
clientSecret
};
} catch (err) {
if (err instanceof URIError) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: invalid percent-encoding");
throw err;
}
}
/**
* Extract client_id from the request. Method-agnostic -- runs before the client
* record is loaded. Only the Basic scheme is recognized; other Authorization
* header schemes are ignored (client_id comes from body only).
*/
resolveClientId(params) {
const basicAuth = this.parseBasicAuth(params.authorization_header);
const headerClientId = basicAuth?.clientId;
const bodyClientId = params.client_id;
if (headerClientId && bodyClientId && headerClientId !== bodyClientId) throw new OAuthError(400, "invalid_request", "client_id mismatch between Authorization header and request body");
const clientId = headerClientId ?? bodyClientId;
if (!clientId) throw new OAuthError(400, "invalid_request", "client_id is required");
return {
clientId,
basicAuth
};
}
static WWW_AUTH_BASIC = { "WWW-Authenticate": "Basic realm=\"directus\"" };
/**
* Enforce that the request matches the client's registered auth method exactly.
* Accepts pre-parsed Basic auth from resolveClientId to avoid double decoding.
* If preParsedBasicAuth is not provided, parses from params.authorization_header.
*/
authenticateClient(clientRecord, params, preParsedBasicAuth) {
const authMethod = clientRecord["token_endpoint_auth_method"];
const basicAuth = preParsedBasicAuth !== void 0 ? preParsedBasicAuth : this.parseBasicAuth(params.authorization_header);
const hasBasicHeader = basicAuth !== null;
const hasBodySecret = typeof params.client_secret === "string" && params.client_secret.length > 0;
if (authMethod === "none") {
if (hasBasicHeader) throw new OAuthError(400, "invalid_request", "Authorization header not allowed for public clients");
if (hasBodySecret) throw new OAuthError(400, "invalid_request", "client_secret not allowed for public clients");
return;
}
if (authMethod === "client_secret_basic") {
if (hasBodySecret) throw new OAuthError(400, "invalid_request", "client_secret in body not allowed for client_secret_basic");
if (!hasBasicHeader) throw new OAuthError(401, "invalid_client", "Authorization header required for client_secret_basic", false, McpOAuthService.WWW_AUTH_BASIC);
this.verifySecret(basicAuth.clientSecret, clientRecord, McpOAuthService.WWW_AUTH_BASIC);
return;
}
if (authMethod === "client_secret_post") {
if (hasBasicHeader) throw new OAuthError(400, "invalid_request", "Authorization header not allowed for client_secret_post");
if (!hasBodySecret) throw new OAuthError(401, "invalid_client", "client_secret required for client_secret_post");
this.verifySecret(params.client_secret, clientRecord);
return;
}
throw new OAuthError(401, "invalid_client", "Unsupported authentication method");
}
/** Timing-safe secret verification with hex format guard and length pre-check. */
verifySecret(providedSecret, clientRecord, errorHeaders = {}) {
const storedHash = clientRecord["client_secret_hash"];
if (!storedHash || !SHA256_HEX_RE.test(storedHash)) throw new OAuthError(401, "invalid_client", "Client authentication failed", false, errorHeaders);
const computedHash = this.hashToken(providedSecret);
const hashA = Buffer.from(computedHash, "hex");
const hashB = Buffer.from(storedHash, "hex");
if (hashA.length !== hashB.length || !crypto.timingSafeEqual(hashA, hashB)) throw new OAuthError(401, "invalid_client", "Client authentication failed", false, errorHeaders);
}
/**
* First contact: fetch CIMD metadata, INSERT new client row.
* Handles concurrent inserts via unique constraint catch + SELECT fallback.
*/
async insertCimdClient(clientId) {
const env = useEnv();
const logger = useLogger();
const parsed = Number(env["MCP_OAUTH_MAX_CLIENTS"]);
const maxClients = Number.isNaN(parsed) ? 1e4 : parsed;
if (maxClients > 0) {
const [{ count }] = await this.knex("directus_oauth_clients").count("* as count");
if (Number(count) >= maxClients) throw new OAuthError(400, "invalid_client", "Maximum number of registered clients reached");
}
const result = await fetchCimdMetadata(clientId);
if (result.notModified || !result.metadata) throw new OAuthError(400, "invalid_client_metadata", "Unexpected 304 on first contact");
const metadata = result.metadata;
const now = /* @__PURE__ */ new Date();
const ttlMs = result.ttlMs ?? DEFAULT_CIMD_TTL_MS;
const expiresAt = ttlMs > 0 ? new Date(now.getTime() + ttlMs) : now;
const row = {
client_id: metadata.client_id,
client_name: metadata.client_name,
redirect_uris: JSON.stringify(metadata.redirect_uris),
grant_types: JSON.stringify(metadata.grant_types),
token_endpoint_auth_method: metadata.token_endpoint_auth_method,
registration_type: "cimd",
client_uri: metadata.client_uri ?? null,
logo_uri: metadata.logo_uri ?? null,
tos_uri: metadata.tos_uri ?? null,
policy_uri: metadata.policy_uri ?? null,
metadata_fetched_at: now,
metadata_expires_at: expiresAt,
metadata_etag: result.etag ?? null
};
try {
await this.knex("directus_oauth_clients").insert(row);
logger.info({ client_id: clientId }, "CIMD client registered");
return row;
} catch (err) {
if (await translateDatabaseError(err, row) instanceof RecordNotUniqueError) {
logger.debug({ client_id: clientId }, "CIMD concurrent insert, selecting existing");
const existing = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
if (!existing) throw new OAuthError(400, "invalid_client", "Failed to register CIMD client");
return existing;
}
throw err;
}
}
/**
* Stale cache: re-fetch CIMD metadata with conditional request support.
* On 304: recompute TTL and update timestamps.
* On 200: validate and update full row.
* On failure: block request (don't serve stale).
*/
async refreshCimdClient(existing) {
const logger = useLogger();
const clientId = existing["client_id"];
const storedEtag = existing["metadata_etag"];
try {
const result = await fetchCimdMetadata(clientId, storedEtag ?? void 0);
if (result.notModified) {
const now = /* @__PURE__ */ new Date();
let newTtlMs;
if (result.ttlMs !== null) newTtlMs = result.ttlMs;
else {
const prevTtl = existing["metadata_expires_at"] && existing["metadata_fetched_at"] ? new Date(existing["metadata_expires_at"]).getTime() - new Date(existing["metadata_fetched_at"]).getTime() : null;
newTtlMs = prevTtl !== null && prevTtl >= 0 ? prevTtl : DEFAULT_CIMD_TTL_MS;
}
const expiresAt = newTtlMs > 0 ? new Date(now.getTime() + newTtlMs) : now;
await this.knex("directus_oauth_clients").where("client_id", clientId).update({
metadata_fetched_at: now,
metadata_expires_at: expiresAt
});
logger.debug({ client_id: clientId }, "CIMD metadata revalidated (304)");
return {
...existing,
metadata_fetched_at: now,
metadata_expires_at: expiresAt
};
}
return await this.updateCimdClient(existing, result.metadata, result.etag ?? null, result.ttlMs ?? DEFAULT_CIMD_TTL_MS);
} catch (err) {
logger.warn({
client_id: clientId,
err: err instanceof Error ? err.message : err
}, "CIMD re-fetch failed");
if (err instanceof OAuthError) throw err;
throw new OAuthError(400, "invalid_client", "Failed to revalidate client metadata");
}
}
/**
* UPDATE all metadata columns + cache timestamps for a CIMD client.
*/
async updateCimdClient(existing, metadata, etag, ttlMs) {
const logger = useLogger();
const now = /* @__PURE__ */ new Date();
const expiresAt = ttlMs > 0 ? new Date(now.getTime() + ttlMs) : now;
const updates = {
client_name: metadata.client_name,
redirect_uris: JSON.stringify(metadata.redirect_uris),
grant_types: JSON.stringify(metadata.grant_types),
token_endpoint_auth_method: metadata.token_endpoint_auth_method,
client_uri: metadata.client_uri ?? null,
logo_uri: metadata.logo_uri ?? null,
tos_uri: metadata.tos_uri ?? null,
policy_uri: metadata.policy_uri ?? null,
metadata_fetched_at: now,
metadata_expires_at: expiresAt,
metadata_etag: etag
};
await this.knex("directus_oauth_clients").where("client_id", existing["client_id"]).update(updates);
logger.info({ client_id: existing["client_id"] }, "CIMD metadata refreshed");
return {
...existing,
...updates
};
}
buildRedirectUrl(redirectUri, params, state, issuerUrl) {
const url = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
if (state) url.searchParams.set("state", state);
url.searchParams.set("iss", issuerUrl);
return url.toString();
}
async issueMcpAccessToken(opts) {
const rolesTree = await fetchRolesTree(opts.role, { knex: this.knex });
const globalAccess = await fetchGlobalAccess({
user: opts.userId,
roles: rolesTree,
ip: opts.ip
}, { knex: this.knex });
return jwt.sign({
id: opts.userId,
role: opts.role,
app_access: globalAccess.app,
admin_access: globalAccess.admin,
session: opts.sessionHash,
scope: MCP_ACCESS_SCOPE,
aud: opts.resource
}, getSecret(), {
expiresIn: opts.accessTtl / 1e3,
issuer: "directus"
});
}
async recordOAuthActivity(opts) {
await new ActivityService({
knex: this.knex,
schema: this.schema
}).createOne({
action: opts.action,
user: opts.userId,
collection: "directus_oauth_tokens",
item: opts.grantId,
comment: opts.comment,
ip: opts.ip,
user_agent: opts.userAgent
});
}
async requireActiveUser(userId, knex) {
const userRecord = await knex("directus_users").where("id", userId).select("email", "status", "role").first();
if (!userRecord || userRecord.status !== "active") throw new OAuthError(400, "invalid_grant", "User account is not active");
return {
email: userRecord.email ?? "unknown",
role: userRecord.role ?? null
};
}
/** Detect and revoke grants where previous_session matches (refresh token reuse). */
async detectReuse(db, oldSessionHash, clientId, logger) {
const reuseGrant = await db("directus_oauth_tokens").where({
previous_session: oldSessionHash,
client: clientId
}).first();
if (reuseGrant) {
await db("directus_oauth_tokens").where({
id: reuseGrant["id"],
previous_session: oldSessionHash,
client: clientId
}).delete();
await db("directus_sessions").where({
token: reuseGrant["session"],
user: reuseGrant["user"],
oauth_client: clientId
}).delete();
logger.warn({
client_id: clientId,
grant_id: reuseGrant["id"]
}, "Refresh token reuse detected, grant revoked");
}
}
/** Detect and revoke a grant issued from a replayed authorization code. */
async revokeGrantByCodeHash(db, codeHash, clientId) {
const replayGrant = await db("directus_oauth_tokens").where({
code_hash: codeHash,
client: clientId
}).first();
if (replayGrant) {
await db("directus_oauth_tokens").where("id", replayGrant["id"]).delete();
await db("directus_sessions").where("token", replayGrant["session"]).delete();
}
}
/** HMAC-SHA256 key derived from SECRET for signing/verifying consent JWTs. Domain-separated to prevent token confusion. */
getConsentKey() {
return crypto.createHmac("sha256", getSecret()).update("mcp-oauth-consent-v1").digest();
}
hashToken(token, encoding = "hex") {
return crypto.createHash("sha256").update(token).digest(encoding);
}
validateRedirectUri(uri) {
validateRedirectUri(uri);
}
};
//#endregion
export { McpOAuthService, OAuthError, isDomainAllowed, isLoopbackHost, validateRedirectUri };