UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

1,185 lines 57.8 kB
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 };