@directus/api 35.2.0 → 36.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/ai/chat/models/chat-request.js +48 -48
  2. package/dist/ai/chat/models/object-request.js +6 -6
  3. package/dist/ai/chat/models/providers.js +14 -14
  4. package/dist/ai/chat/utils/parse-json-schema-7.js +22 -22
  5. package/dist/ai/mcp/server.js +44 -6
  6. package/dist/ai/mcp/utils.js +31 -0
  7. package/dist/ai/tools/assets/index.js +3 -3
  8. package/dist/ai/tools/collections/index.js +18 -18
  9. package/dist/ai/tools/fields/index.js +18 -18
  10. package/dist/ai/tools/files/index.js +18 -18
  11. package/dist/ai/tools/flows/index.js +16 -16
  12. package/dist/ai/tools/folders/index.js +18 -18
  13. package/dist/ai/tools/items/index.js +17 -17
  14. package/dist/ai/tools/operations/index.js +16 -16
  15. package/dist/ai/tools/relations/index.js +22 -22
  16. package/dist/ai/tools/schema/index.js +3 -3
  17. package/dist/ai/tools/schema.js +159 -159
  18. package/dist/ai/tools/system/index.js +3 -3
  19. package/dist/ai/tools/trigger-flow/index.js +3 -3
  20. package/dist/app.js +33 -9
  21. package/dist/auth/drivers/ldap.js +3 -1
  22. package/dist/auth/drivers/local.js +2 -0
  23. package/dist/auth/drivers/oauth2.js +3 -1
  24. package/dist/auth/drivers/openid.js +3 -1
  25. package/dist/auth/drivers/saml.js +2 -0
  26. package/dist/auth/utils/check-local-disabled.js +16 -0
  27. package/dist/auth/utils/check-sso-enabled.js +14 -0
  28. package/dist/auth.js +8 -5
  29. package/dist/cli/commands/bootstrap/index.js +3 -0
  30. package/dist/cli/commands/cache/clear.js +6 -1
  31. package/dist/cli/commands/roles/create.js +4 -1
  32. package/dist/cli/commands/users/create.js +3 -0
  33. package/dist/constants.js +8 -1
  34. package/dist/controllers/access.js +1 -1
  35. package/dist/controllers/activity.js +2 -1
  36. package/dist/controllers/assets.js +2 -0
  37. package/dist/controllers/auth.js +13 -5
  38. package/dist/controllers/collections.js +1 -1
  39. package/dist/controllers/comments.js +1 -1
  40. package/dist/controllers/dashboards.js +1 -1
  41. package/dist/controllers/fields.js +1 -1
  42. package/dist/controllers/files.js +3 -1
  43. package/dist/controllers/flows.js +6 -5
  44. package/dist/controllers/folders.js +1 -1
  45. package/dist/controllers/graphql.js +2 -0
  46. package/dist/controllers/items.js +3 -1
  47. package/dist/controllers/license.js +119 -0
  48. package/dist/controllers/mcp/index.js +38 -0
  49. package/dist/controllers/mcp/oauth-clients.js +68 -0
  50. package/dist/controllers/mcp/oauth-consent-page.js +316 -0
  51. package/dist/controllers/mcp/oauth.js +381 -0
  52. package/dist/controllers/mcp/templates/oauth-consent.liquid +62 -0
  53. package/dist/controllers/mcp/templates/oauth-error.liquid +28 -0
  54. package/dist/controllers/notifications.js +1 -1
  55. package/dist/controllers/operations.js +1 -1
  56. package/dist/controllers/panels.js +1 -1
  57. package/dist/controllers/permissions.js +1 -1
  58. package/dist/controllers/policies.js +1 -1
  59. package/dist/controllers/presets.js +1 -1
  60. package/dist/controllers/revisions.js +3 -2
  61. package/dist/controllers/roles.js +1 -1
  62. package/dist/controllers/server.js +38 -9
  63. package/dist/controllers/shares.js +1 -1
  64. package/dist/controllers/translations.js +1 -1
  65. package/dist/controllers/users.js +1 -1
  66. package/dist/controllers/utils.js +2 -2
  67. package/dist/controllers/versions.js +12 -5
  68. package/dist/database/get-ast-from-query/lib/convert-wildcards.js +10 -1
  69. package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -1
  70. package/dist/database/helpers/fn/dialects/mysql.js +7 -12
  71. package/dist/database/helpers/fn/dialects/oracle.js +3 -4
  72. package/dist/database/helpers/fn/dialects/postgres.js +4 -26
  73. package/dist/database/helpers/fn/json/mysql-json-path.js +22 -0
  74. package/dist/database/helpers/fn/json/parse-function.js +14 -6
  75. package/dist/database/helpers/fn/json/postgres-json-path.js +54 -0
  76. package/dist/database/migrations/20260110A-add-ai-provider-settings.js +4 -4
  77. package/dist/database/migrations/20260217A-null-item-versions.js +14 -0
  78. package/dist/database/migrations/20260312A-add-ai-translation-settings.js +18 -0
  79. package/dist/database/migrations/20260507A-add-licensing.js +22 -0
  80. package/dist/database/migrations/20260512A-add-autosave-revision-interval.js +14 -0
  81. package/dist/database/migrations/20260512B-add-mcp-oauth.js +87 -0
  82. package/dist/database/run-ast/lib/apply-query/filter/operator.js +116 -33
  83. package/dist/database/run-ast/lib/apply-query/index.js +4 -1
  84. package/dist/database/run-ast/lib/apply-query/sort.js +17 -7
  85. package/dist/database/run-ast/lib/get-db-query.js +21 -9
  86. package/dist/database/run-ast/lib/parse-current-level.js +2 -1
  87. package/dist/database/run-ast/run-ast.js +2 -1
  88. package/dist/database/run-ast/utils/get-column.js +2 -1
  89. package/dist/extensions/lib/installation/manager.js +1 -1
  90. package/dist/extensions/lib/sandbox/register/operation.js +1 -1
  91. package/dist/extensions/lib/sync/sync.js +1 -1
  92. package/dist/extensions/manager.js +3 -3
  93. package/dist/flows.js +5 -5
  94. package/dist/license/entitlements/lib/collections.js +37 -0
  95. package/dist/license/entitlements/lib/custom-llms-enabled.js +18 -0
  96. package/dist/license/entitlements/lib/custom-permission-rules-enabled.js +41 -0
  97. package/dist/license/entitlements/lib/flows.js +29 -0
  98. package/dist/license/entitlements/lib/seats.js +103 -0
  99. package/dist/license/entitlements/lib/sso-enabled.js +45 -0
  100. package/dist/license/entitlements/manager.js +256 -0
  101. package/dist/license/index.js +4 -0
  102. package/dist/license/manager.js +505 -0
  103. package/dist/license/utils/compute-license-status.js +27 -0
  104. package/dist/license/utils/get-core-grace-expires-at.js +38 -0
  105. package/dist/license/utils/get-license-key.js +23 -0
  106. package/dist/license/utils/get-license-token.js +23 -0
  107. package/dist/license/utils/handle-license-error.js +41 -0
  108. package/dist/license/utils/is-in-core-grace-period.js +11 -0
  109. package/dist/license/utils/is-sso-bypass-allowed.js +21 -0
  110. package/dist/license/utils/use-rpc.js +33 -0
  111. package/dist/middleware/cache.js +4 -1
  112. package/dist/middleware/error-handler.js +11 -0
  113. package/dist/middleware/extract-token.js +11 -2
  114. package/dist/middleware/is-admin.js +16 -0
  115. package/dist/middleware/is-locked.js +16 -0
  116. package/dist/middleware/mcp-oauth-guard.js +23 -0
  117. package/dist/middleware/request-counter.js +5 -2
  118. package/dist/packages/types/dist/index.js +117 -122
  119. package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +10 -1
  120. package/dist/permissions/utils/get-unaliased-field-key.js +2 -1
  121. package/dist/request/is-denied-ip.js +2 -0
  122. package/dist/schedules/license.js +31 -0
  123. package/dist/schedules/oauth-cleanup.js +26 -0
  124. package/dist/schedules/retention.js +1 -1
  125. package/dist/schedules/telemetry.js +4 -1
  126. package/dist/schedules/tus.js +1 -1
  127. package/dist/schedules/utils/duration-to-cron.js +36 -0
  128. package/dist/services/activity.js +15 -0
  129. package/dist/services/authentication.js +12 -5
  130. package/dist/services/collections.js +40 -10
  131. package/dist/services/fields.js +6 -6
  132. package/dist/services/flows.js +12 -0
  133. package/dist/services/graphql/resolvers/system-admin.js +2 -2
  134. package/dist/services/graphql/resolvers/system-global.js +1 -1
  135. package/dist/services/graphql/resolvers/system.js +34 -18
  136. package/dist/services/graphql/schema/get-types.js +23 -2
  137. package/dist/services/graphql/schema/parse-query.js +8 -0
  138. package/dist/services/graphql/schema/read.js +12 -0
  139. package/dist/services/graphql/types/json-filter.js +30 -0
  140. package/dist/services/index.js +6 -6
  141. package/dist/services/items.js +32 -14
  142. package/dist/services/mcp-oauth/cimd.js +307 -0
  143. package/dist/services/mcp-oauth/index.js +1185 -0
  144. package/dist/services/mcp-oauth/types/error.js +22 -0
  145. package/dist/services/mcp-oauth/utils/cimd-egress.js +182 -0
  146. package/dist/services/mcp-oauth/utils/domain.js +21 -0
  147. package/dist/services/mcp-oauth/utils/loopback.js +11 -0
  148. package/dist/services/mcp-oauth/utils/redirect.js +84 -0
  149. package/dist/services/mcp-oauth/utils/registration-debug.js +131 -0
  150. package/dist/services/payload.js +2 -1
  151. package/dist/services/permissions.js +31 -9
  152. package/dist/services/revisions.js +15 -0
  153. package/dist/services/server.js +21 -4
  154. package/dist/services/settings.js +37 -3
  155. package/dist/services/users.js +13 -6
  156. package/dist/services/utils.js +6 -1
  157. package/dist/services/versions.js +137 -69
  158. package/dist/utils/calculate-field-depth.js +1 -0
  159. package/dist/utils/deep-freeze.js +24 -0
  160. package/dist/utils/extract-function-name.js +13 -0
  161. package/dist/utils/generate-translations.js +5 -5
  162. package/dist/utils/get-accountability-for-token.js +13 -1
  163. package/dist/utils/get-cache-key.js +1 -1
  164. package/dist/utils/get-history-filter-query.js +22 -0
  165. package/dist/utils/get-schema.js +2 -2
  166. package/dist/utils/get-service.js +3 -3
  167. package/dist/utils/is-admin.js +9 -0
  168. package/dist/utils/parse-oauth-scope.js +12 -0
  169. package/dist/utils/sanitize-query.js +1 -1
  170. package/dist/utils/split-field-path.js +29 -0
  171. package/dist/utils/transaction.js +2 -2
  172. package/dist/utils/translations-validation.js +2 -2
  173. package/dist/utils/validate-query.js +35 -4
  174. package/dist/utils/validate-user-count-integrity.js +28 -5
  175. package/dist/utils/verify-session-jwt.js +5 -2
  176. package/dist/utils/versioning/handle-version.js +130 -48
  177. package/dist/utils/versioning/remove-circular.js +17 -0
  178. package/dist/websocket/authenticate.js +2 -1
  179. package/dist/websocket/collab/collab.js +1 -1
  180. package/dist/websocket/collab/room.js +1 -1
  181. package/dist/websocket/controllers/base.js +12 -0
  182. package/dist/websocket/controllers/graphql.js +1 -1
  183. package/dist/websocket/handlers/subscribe.js +1 -1
  184. package/dist/websocket/messages.js +64 -64
  185. package/dist/websocket/utils/items.js +2 -2
  186. package/license +90 -80
  187. package/package.json +33 -32
  188. package/dist/controllers/mcp.js +0 -31
@@ -0,0 +1,1185 @@
1
+ import { useLogger } from "../../logger/index.js";
2
+ import { getMilliseconds } from "../../utils/get-milliseconds.js";
3
+ import database_default from "../../database/index.js";
4
+ import { fetchRolesTree } from "../../permissions/lib/fetch-roles-tree.js";
5
+ import { fetchGlobalAccess } from "../../permissions/modules/fetch-global-access/fetch-global-access.js";
6
+ import { transaction } from "../../utils/transaction.js";
7
+ import { translateDatabaseError } from "../../database/errors/translate.js";
8
+ import { getSecret } from "../../utils/get-secret.js";
9
+ import { ActivityService } from "../activity.js";
10
+ import { Url } from "../../utils/url.js";
11
+ import { parseOAuthScope } from "../../utils/parse-oauth-scope.js";
12
+ import { MCP_ACCESS_SCOPE, getMcpUrls } from "../../ai/mcp/utils.js";
13
+ import { OAuthError } from "./types/error.js";
14
+ import { isDomainAllowed } from "./utils/domain.js";
15
+ import { isLoopbackHost } from "./utils/loopback.js";
16
+ import { matchRedirectUri, validateRedirectUri } from "./utils/redirect.js";
17
+ import { detectClientIdType, fetchCimdMetadata, getAllowedDomains } from "./cimd.js";
18
+ import { summarizeDcrRegistrationMetadata } from "./utils/registration-debug.js";
19
+ import { useEnv } from "@directus/env";
20
+ import { RecordNotUniqueError } from "@directus/errors";
21
+ import { isObject, parseJSON, toBoolean } from "@directus/utils";
22
+ import { Action } from "@directus/constants";
23
+ import crypto from "node:crypto";
24
+ import jwt from "jsonwebtoken";
25
+
26
+ //#region src/services/mcp-oauth/index.ts
27
+ const DEFAULT_UNUSED_CLIENT_TTL_MS = 4320 * 60 * 1e3;
28
+ const DEFAULT_CIMD_TTL_MS = 36e5;
29
+ const MAX_REDIRECT_URIS = 10;
30
+ const MAX_CLIENT_NAME_LENGTH = 200;
31
+ /** Consent JWT typ claim -- prevents token confusion with regular Directus JWTs */
32
+ const CONSENT_JWT_TYP = "directus-mcp-consent+jwt";
33
+ /** Consent JWT audience -- binds the token to the decision endpoint */
34
+ const CONSENT_JWT_AUD = "mcp-oauth-authorize-decision";
35
+ function parseStringArrayField(value, field) {
36
+ let parsed = value;
37
+ if (typeof value === "string") parsed = parseJSON(value);
38
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) throw new Error(`Invalid OAuth client ${field}: expected an array of strings`);
39
+ return parsed;
40
+ }
41
+ /** RFC 7636 Section 4.1: code_verifier uses unreserved characters, 43-128 length */
42
+ const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
43
+ /** RFC 7636 Section 4.2: S256 code_challenge is base64url-encoded SHA-256 (always 43 chars) */
44
+ const CODE_CHALLENGE_S256_RE = /^[A-Za-z0-9_-]{43}$/;
45
+ /** RFC 6749 Section 9 token endpoint auth methods supported by this server */
46
+ const SUPPORTED_TOKEN_AUTH_METHODS = [
47
+ "none",
48
+ "client_secret_basic",
49
+ "client_secret_post"
50
+ ];
51
+ /** SHA-256 hash hex format guard (64 hex chars = 256 bits) */
52
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
53
+ /** Client secret byte length for confidential clients (32 bytes = 256 bits) */
54
+ const CLIENT_SECRET_BYTES = 32;
55
+ /** Params checked for duplicates before redirect_uri validation (non-redirectable errors) */
56
+ const PRE_TRUST_DUPLICATE_PARAMS = ["client_id", "redirect_uri"];
57
+ /** Params checked for duplicates after redirect_uri validation (redirectable errors) */
58
+ const POST_TRUST_DUPLICATE_PARAMS = [
59
+ "response_type",
60
+ "code_challenge",
61
+ "code_challenge_method",
62
+ "scope",
63
+ "resource",
64
+ "state",
65
+ "response_mode"
66
+ ];
67
+ function checkDuplicateParams(params, keys, redirectable) {
68
+ for (const key of keys) if (Array.isArray(params[key])) throw new OAuthError(400, "invalid_request", `Duplicate parameter: ${key}`, redirectable);
69
+ }
70
+ function getStringParam(params, key, redirectable) {
71
+ const value = params[key];
72
+ if (value === void 0) return;
73
+ if (typeof value !== "string") throw new OAuthError(400, "invalid_request", `${key} must be a string`, redirectable);
74
+ return value;
75
+ }
76
+ /**
77
+ * OAuth 2.1 authorization server for MCP (Model Context Protocol) access.
78
+ *
79
+ * Implements public-client profile with mandatory PKCE:
80
+ * - RFC 6749 / OAuth 2.1: authorization code grant, refresh token rotation
81
+ * - RFC 7636: PKCE (S256 only, required for all flows)
82
+ * - RFC 7591: Dynamic Client Registration (public clients, MCP_OAUTH_MAX_CLIENTS cap)
83
+ * - RFC 8707: Resource Indicators (`resource` param bound to token audience)
84
+ * - RFC 9728: Protected Resource Metadata discovery
85
+ * - RFC 8414: Authorization Server Metadata discovery
86
+ *
87
+ * Security properties: codes stored as SHA-256 hashes, PKCE verified with timing-safe
88
+ * compare, authorization codes burned atomically (UPDATE WHERE used_at IS NULL) before
89
+ * validation, refresh tokens rotated with reuse detection.
90
+ */
91
+ var McpOAuthService = class McpOAuthService {
92
+ knex;
93
+ accountability;
94
+ schema;
95
+ constructor(options) {
96
+ this.knex = options.knex || database_default();
97
+ this.accountability = options.accountability || null;
98
+ this.schema = options.schema;
99
+ }
100
+ /**
101
+ * RFC 9728 Protected Resource Metadata.
102
+ *
103
+ * Called by `GET /.well-known/oauth-protected-resource*`. This is the first endpoint an MCP
104
+ * client hits -- it discovers which authorization server protects the `/mcp` resource.
105
+ */
106
+ getProtectedResourceMetadata() {
107
+ const { issuerUrl, resourceUrl } = getMcpUrls();
108
+ return {
109
+ resource: resourceUrl,
110
+ authorization_servers: [issuerUrl],
111
+ scopes_supported: [MCP_ACCESS_SCOPE],
112
+ bearer_methods_supported: ["header"]
113
+ };
114
+ }
115
+ /**
116
+ * RFC 8414 Authorization Server Metadata.
117
+ *
118
+ * Called by `GET /.well-known/oauth-authorization-server*`. The client fetches this after
119
+ * discovering the AS from the protected resource metadata, then uses the endpoint URLs
120
+ * to register (DCR) and start the authorization flow.
121
+ *
122
+ * Queries settings to conditionally include `registration_endpoint` (DCR) and
123
+ * `client_id_metadata_document_supported` (CIMD).
124
+ */
125
+ async getAuthorizationServerMetadata() {
126
+ const { issuerUrl } = getMcpUrls();
127
+ const env = useEnv();
128
+ const baseUrl = env["PUBLIC_URL"];
129
+ const settings = await this.knex("directus_settings").select("mcp_oauth_dcr_enabled", "mcp_oauth_cimd_enabled").first();
130
+ const dcrEnabled = toBoolean(env["MCP_OAUTH_DCR_ENABLED"]) && toBoolean(settings?.mcp_oauth_dcr_enabled);
131
+ const cimdEnabled = toBoolean(env["MCP_OAUTH_CIMD_ENABLED"]) && toBoolean(settings?.mcp_oauth_cimd_enabled);
132
+ const authorizationEndpoint = new Url(baseUrl).addPath("mcp-oauth", "authorize");
133
+ const tokenEndpoint = new Url(baseUrl).addPath("mcp-oauth", "token");
134
+ const revocationEndpoint = new Url(baseUrl).addPath("mcp-oauth", "revoke");
135
+ const metadata = {
136
+ issuer: issuerUrl,
137
+ authorization_endpoint: authorizationEndpoint.toString(),
138
+ token_endpoint: tokenEndpoint.toString(),
139
+ revocation_endpoint: revocationEndpoint.toString(),
140
+ response_types_supported: ["code"],
141
+ grant_types_supported: ["authorization_code", "refresh_token"],
142
+ token_endpoint_auth_methods_supported: SUPPORTED_TOKEN_AUTH_METHODS,
143
+ revocation_endpoint_auth_methods_supported: SUPPORTED_TOKEN_AUTH_METHODS,
144
+ code_challenge_methods_supported: ["S256"],
145
+ scopes_supported: [MCP_ACCESS_SCOPE],
146
+ response_modes_supported: ["query"],
147
+ authorization_response_iss_parameter_supported: true
148
+ };
149
+ if (dcrEnabled) metadata["registration_endpoint"] = new Url(baseUrl).addPath("mcp-oauth", "register").toString();
150
+ if (cimdEnabled) metadata["client_id_metadata_document_supported"] = true;
151
+ return metadata;
152
+ }
153
+ /**
154
+ * RFC 7591 Dynamic Client Registration.
155
+ *
156
+ * Called by `POST /mcp-oauth/register`. This is how MCP clients self-register before
157
+ * starting the authorization flow. No authentication required.
158
+ *
159
+ * Validates: client_name (Directus policy), redirect_uris (HTTPS or localhost, no fragments),
160
+ * grant_types (defaults to authorization_code), token_endpoint_auth_method.
161
+ * Enforces a global cap via MCP_OAUTH_MAX_CLIENTS (default 10,000). client_id is a random UUID (not a secret).
162
+ *
163
+ * @param body - Raw request body (validated internally)
164
+ * @returns DCR response with assigned client_id
165
+ * @throws {OAuthError} `invalid_client_metadata` or `invalid_redirect_uri`
166
+ */
167
+ async registerClient(body) {
168
+ const env = useEnv();
169
+ const logger = useLogger();
170
+ if (!toBoolean(env["MCP_OAUTH_DCR_ENABLED"])) {
171
+ logger.debug({ reason: "dcr_env_disabled" }, "MCP OAuth DCR registration rejected");
172
+ throw new OAuthError(404, "not_found", "Dynamic client registration is not available");
173
+ }
174
+ if (!toBoolean((await this.knex("directus_settings").select("mcp_oauth_dcr_enabled").first())?.mcp_oauth_dcr_enabled)) {
175
+ logger.debug({ reason: "dcr_setting_disabled" }, "MCP OAuth DCR registration rejected");
176
+ throw new OAuthError(404, "not_found", "Dynamic client registration is not available");
177
+ }
178
+ function rejectRegistration(code, description) {
179
+ logger.debug({
180
+ code,
181
+ description,
182
+ registration: summarizeDcrRegistrationMetadata(input)
183
+ }, "MCP OAuth DCR validation failed");
184
+ throw new OAuthError(400, code, description);
185
+ }
186
+ if (!isObject(body)) {
187
+ logger.debug({
188
+ code: "invalid_client_metadata",
189
+ description: "Registration metadata must be an object",
190
+ registration: summarizeDcrRegistrationMetadata(body)
191
+ }, "MCP OAuth DCR validation failed");
192
+ throw new OAuthError(400, "invalid_client_metadata", "Registration metadata must be an object");
193
+ }
194
+ const input = body;
195
+ const clientName = input["client_name"];
196
+ if (typeof clientName !== "string" || clientName.length === 0) rejectRegistration("invalid_client_metadata", "client_name is required");
197
+ if (clientName.length > MAX_CLIENT_NAME_LENGTH) rejectRegistration("invalid_client_metadata", `client_name must not exceed ${MAX_CLIENT_NAME_LENGTH} characters`);
198
+ const redirectUris = input["redirect_uris"];
199
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) rejectRegistration("invalid_redirect_uri", "At least one redirect_uri is required");
200
+ if (redirectUris.length > MAX_REDIRECT_URIS) rejectRegistration("invalid_redirect_uri", `Maximum ${MAX_REDIRECT_URIS} redirect URIs allowed`);
201
+ for (const uri of redirectUris) try {
202
+ this.validateRedirectUri(uri);
203
+ } catch (err) {
204
+ if (err instanceof OAuthError) logger.debug({
205
+ code: err.code,
206
+ description: err.description,
207
+ registration: summarizeDcrRegistrationMetadata(input)
208
+ }, "MCP OAuth DCR validation failed");
209
+ throw err;
210
+ }
211
+ const grantTypes = input["grant_types"] === void 0 ? ["authorization_code"] : input["grant_types"];
212
+ 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");
213
+ if (!grantTypes.includes("authorization_code")) rejectRegistration("invalid_client_metadata", "grant_types must include authorization_code");
214
+ const allowedGrantTypes = ["authorization_code", "refresh_token"];
215
+ if (grantTypes.some((gt) => !allowedGrantTypes.includes(gt))) rejectRegistration("invalid_client_metadata", "Unsupported grant type");
216
+ const authMethod = input["token_endpoint_auth_method"] ?? "client_secret_basic";
217
+ if (!SUPPORTED_TOKEN_AUTH_METHODS.includes(authMethod)) rejectRegistration("invalid_client_metadata", `Unsupported token_endpoint_auth_method: ${authMethod}`);
218
+ const responseTypes = input["response_types"];
219
+ if (responseTypes !== void 0) {
220
+ if (!Array.isArray(responseTypes) || responseTypes.length !== 1 || responseTypes[0] !== "code") rejectRegistration("invalid_client_metadata", "Only response_types [\"code\"] is supported");
221
+ }
222
+ const optionalUriFields = [
223
+ "client_uri",
224
+ "logo_uri",
225
+ "tos_uri",
226
+ "policy_uri"
227
+ ];
228
+ const optionalUris = {};
229
+ for (const field of optionalUriFields) {
230
+ const value = input[field];
231
+ if (value !== void 0 && value !== null) {
232
+ if (typeof value !== "string") rejectRegistration("invalid_client_metadata", `${field} must be a string`);
233
+ try {
234
+ if (new URL(value).protocol !== "https:") rejectRegistration("invalid_client_metadata", `${field} must use HTTPS`);
235
+ } catch (err) {
236
+ if (err instanceof OAuthError) throw err;
237
+ rejectRegistration("invalid_client_metadata", `${field} is not a valid URL`);
238
+ }
239
+ optionalUris[field] = value;
240
+ } else optionalUris[field] = null;
241
+ }
242
+ const parsed = Number(env["MCP_OAUTH_MAX_CLIENTS"]);
243
+ const maxClients = Number.isNaN(parsed) ? 1e4 : parsed;
244
+ if (maxClients > 0) {
245
+ const [{ count }] = await this.knex("directus_oauth_clients").count("* as count");
246
+ if (Number(count) >= maxClients) rejectRegistration("invalid_client_metadata", "Maximum number of registered clients reached");
247
+ }
248
+ const isConfidential = authMethod !== "none";
249
+ let clientSecret;
250
+ let clientSecretHash = null;
251
+ if (isConfidential) {
252
+ clientSecret = crypto.randomBytes(CLIENT_SECRET_BYTES).toString("base64url");
253
+ clientSecretHash = this.hashToken(clientSecret);
254
+ }
255
+ const clientId = crypto.randomUUID();
256
+ const now = Math.floor(Date.now() / 1e3);
257
+ await this.knex("directus_oauth_clients").insert({
258
+ client_id: clientId,
259
+ client_name: clientName,
260
+ redirect_uris: JSON.stringify(redirectUris),
261
+ grant_types: JSON.stringify(grantTypes),
262
+ token_endpoint_auth_method: authMethod,
263
+ client_secret_hash: clientSecretHash,
264
+ registration_type: "dcr",
265
+ client_uri: optionalUris["client_uri"],
266
+ logo_uri: optionalUris["logo_uri"],
267
+ tos_uri: optionalUris["tos_uri"],
268
+ policy_uri: optionalUris["policy_uri"]
269
+ });
270
+ return {
271
+ client_id: clientId,
272
+ client_name: clientName,
273
+ redirect_uris: redirectUris,
274
+ grant_types: grantTypes,
275
+ response_types: ["code"],
276
+ token_endpoint_auth_method: authMethod,
277
+ client_id_issued_at: now,
278
+ ...optionalUris["client_uri"] ? { client_uri: optionalUris["client_uri"] } : {},
279
+ ...optionalUris["logo_uri"] ? { logo_uri: optionalUris["logo_uri"] } : {},
280
+ ...optionalUris["tos_uri"] ? { tos_uri: optionalUris["tos_uri"] } : {},
281
+ ...optionalUris["policy_uri"] ? { policy_uri: optionalUris["policy_uri"] } : {},
282
+ ...isConfidential ? {
283
+ client_secret: clientSecret,
284
+ client_secret_expires_at: 0
285
+ } : {}
286
+ };
287
+ }
288
+ /**
289
+ * Validate an authorization request and produce a signed consent JWT.
290
+ *
291
+ * Called by `GET /mcp-oauth/authorize` (the consent page). The browser redirects here
292
+ * after the client constructs the authorization URL. If validation succeeds, the consent
293
+ * page renders with the signed JWT as a hidden form field.
294
+ *
295
+ * Validation order follows RFC 6749 Section 4.1.2.1 (pre-redirect vs post-redirect errors):
296
+ * 1. client_id lookup -- non-redirectable
297
+ * 2. redirect_uri exact match (RFC 6749 Section 3.1.2) -- non-redirectable
298
+ * 3. response_type (RFC 6749 Section 3.1.1) -- redirectable
299
+ * 4. PKCE code_challenge (RFC 7636 Section 4.4.1) -- redirectable
300
+ * 5. scope (RFC 6749 Section 3.3) -- redirectable
301
+ * 6. resource indicator (RFC 8707 Section 2) -- redirectable
302
+ *
303
+ * The consent JWT (HMAC-SHA256 with derived key, 5min expiry) binds the validated params
304
+ * to the user and session. It is rendered as a hidden form field on the consent page and
305
+ * verified in {@link processDecision} when the user submits the form. This serves as both
306
+ * CSRF protection and authorization parameter integrity.
307
+ *
308
+ * @param params - Authorization query/body params
309
+ * @param userId - Authenticated Directus user ID
310
+ * @param sessionHash - SHA-256 hex of the session token (binds consent to session)
311
+ * @returns Signed consent JWT and client display name
312
+ * @throws {OAuthError} Non-redirectable for client/redirect errors, redirectable after
313
+ */
314
+ async validateAuthorization(params, userId, sessionHash) {
315
+ checkDuplicateParams(params, PRE_TRUST_DUPLICATE_PARAMS, false);
316
+ const clientId = getStringParam(params, "client_id", false);
317
+ const redirectUri = getStringParam(params, "redirect_uri", false);
318
+ if (!clientId) throw new OAuthError(400, "invalid_request", "client_id is required");
319
+ const client = await this.resolveClientWithFetch(clientId);
320
+ if (!redirectUri) throw new OAuthError(400, "invalid_request", "redirect_uri is required");
321
+ this.validateRedirectUri(redirectUri);
322
+ if (!matchRedirectUri(redirectUri, parseStringArrayField(client["redirect_uris"], "redirect_uris"))) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
323
+ checkDuplicateParams(params, POST_TRUST_DUPLICATE_PARAMS, true);
324
+ const responseType = getStringParam(params, "response_type", true);
325
+ const codeChallenge = getStringParam(params, "code_challenge", true);
326
+ const codeChallengeMethod = getStringParam(params, "code_challenge_method", true);
327
+ const scope = getStringParam(params, "scope", true);
328
+ const resource = getStringParam(params, "resource", true);
329
+ const state = getStringParam(params, "state", true);
330
+ if (!responseType) throw new OAuthError(400, "invalid_request", "response_type is required", true);
331
+ if (responseType !== "code") throw new OAuthError(400, "unsupported_response_type", "Only response_type code is supported", true);
332
+ if (!codeChallenge) throw new OAuthError(400, "invalid_request", "code_challenge is required", true);
333
+ if (!CODE_CHALLENGE_S256_RE.test(codeChallenge)) throw new OAuthError(400, "invalid_request", "code_challenge must be a valid S256 challenge", true);
334
+ if (!codeChallengeMethod || codeChallengeMethod !== "S256") throw new OAuthError(400, "invalid_request", "code_challenge_method must be S256", true);
335
+ const parsedScopes = parseOAuthScope(scope);
336
+ if (!(parsedScopes.length > 0 ? parsedScopes : [MCP_ACCESS_SCOPE]).includes(MCP_ACCESS_SCOPE)) throw new OAuthError(400, "invalid_scope", "Scope must include mcp:access", true);
337
+ const normalizedScope = MCP_ACCESS_SCOPE;
338
+ const responseMode = params["response_mode"];
339
+ if (responseMode && responseMode !== "query") throw new OAuthError(400, "invalid_request", "Only response_mode query is supported", true);
340
+ const env = useEnv();
341
+ const { resourceUrl: expectedResource } = getMcpUrls();
342
+ const requireResource = env["MCP_OAUTH_REQUIRE_RESOURCE"] === true;
343
+ const resolvedResource = resource || (!requireResource ? expectedResource : null);
344
+ if (!resolvedResource) throw new OAuthError(400, "invalid_target", "resource is required", true);
345
+ if (resolvedResource !== expectedResource) throw new OAuthError(400, "invalid_target", "resource does not match the protected resource", true);
346
+ const consentKey = this.getConsentKey();
347
+ const signedParams = jwt.sign({
348
+ typ: CONSENT_JWT_TYP,
349
+ aud: CONSENT_JWT_AUD,
350
+ sub: userId,
351
+ session_hash: sessionHash,
352
+ client_id: clientId,
353
+ redirect_uri: redirectUri,
354
+ code_challenge: codeChallenge,
355
+ code_challenge_method: codeChallengeMethod,
356
+ scope: normalizedScope,
357
+ resource: resolvedResource,
358
+ state
359
+ }, consentKey, {
360
+ expiresIn: "5m",
361
+ algorithm: "HS256"
362
+ });
363
+ const registrationType = client["registration_type"] ?? "dcr";
364
+ let clientDomain;
365
+ if (registrationType === "cimd") try {
366
+ clientDomain = new URL(clientId).hostname;
367
+ } catch {}
368
+ return {
369
+ signed_params: signedParams,
370
+ client_name: client["client_name"],
371
+ already_consented: false,
372
+ redirect_uri: redirectUri,
373
+ scope: normalizedScope,
374
+ registration_type: registrationType,
375
+ client_domain: clientDomain
376
+ };
377
+ }
378
+ /**
379
+ * Process the user's consent decision (approve/deny).
380
+ *
381
+ * Called by `POST /mcp-oauth/authorize/decision` when the user submits the consent form.
382
+ * Verifies the consent JWT produced by {@link validateAuthorization} (audience, typ, sub,
383
+ * session binding), then either redirects with `error=access_denied` (RFC 6749 Section 4.1.2.1)
384
+ * or generates an authorization code (32 random bytes, stored as SHA-256 hash) and upserts
385
+ * a consent record. The redirect includes `iss` per RFC 9207 Section 2.
386
+ *
387
+ * Security checks before code issuance:
388
+ * - Consent JWT signature + expiry (HMAC-SHA256, 5min TTL)
389
+ * - `typ` claim prevents token confusion with other JWTs
390
+ * - `sub` must match the authenticated user (prevents cross-user replay)
391
+ * - `session_hash` must match the current session (prevents cross-session replay)
392
+ * - Client re-validated against DB (handles client deletion between consent and decision)
393
+ *
394
+ * @param params - Contains the signed consent JWT and approval boolean
395
+ * @param userId - Authenticated user (must match JWT `sub`)
396
+ * @param sessionToken - Raw session token (hashed and compared to JWT `session_hash`)
397
+ * @returns Redirect URL with `code` (approval) or `error` (denial) query params
398
+ * @throws {OAuthError} If consent JWT is invalid, expired, or session-mismatched
399
+ */
400
+ async processDecision(params, userId, sessionToken) {
401
+ const { signed_params, approved } = params;
402
+ const consentKey = this.getConsentKey();
403
+ let claims;
404
+ try {
405
+ claims = jwt.verify(signed_params, consentKey, {
406
+ algorithms: ["HS256"],
407
+ audience: CONSENT_JWT_AUD
408
+ });
409
+ } catch {
410
+ throw new OAuthError(400, "invalid_request", "Invalid or expired consent token");
411
+ }
412
+ if (claims["typ"] !== CONSENT_JWT_TYP) throw new OAuthError(400, "invalid_request", "Invalid consent token type");
413
+ if (claims["sub"] !== userId) throw new OAuthError(400, "invalid_request", "Consent token user mismatch");
414
+ const currentSessionHash = this.hashToken(sessionToken);
415
+ if (claims["session_hash"] !== currentSessionHash) throw new OAuthError(400, "invalid_request", "Session binding mismatch");
416
+ const redirectUri = claims["redirect_uri"];
417
+ const state = claims["state"];
418
+ const { issuerUrl } = getMcpUrls();
419
+ const clientId = claims["client_id"];
420
+ this.validateRedirectUri(redirectUri);
421
+ const client = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
422
+ if (!client) throw new OAuthError(400, "invalid_request", "Client no longer exists");
423
+ if (!matchRedirectUri(redirectUri, parseStringArrayField(client["redirect_uris"], "redirect_uris"))) throw new OAuthError(400, "invalid_request", "redirect_uri no longer registered for this client");
424
+ if (String(approved) !== "true") return this.buildRedirectUrl(redirectUri, { error: "access_denied" }, state, issuerUrl);
425
+ const rawCode = crypto.randomBytes(CLIENT_SECRET_BYTES).toString("hex");
426
+ const codeHash = this.hashToken(rawCode);
427
+ const env = useEnv();
428
+ const codeExpiry = new Date(Date.now() + getMilliseconds(env["MCP_OAUTH_AUTH_CODE_TTL"], 0));
429
+ await transaction(this.knex, async (trx) => {
430
+ await trx("directus_oauth_codes").insert({
431
+ id: crypto.randomUUID(),
432
+ code_hash: codeHash,
433
+ client: clientId,
434
+ user: userId,
435
+ redirect_uri: redirectUri,
436
+ resource: claims["resource"],
437
+ code_challenge: claims["code_challenge"],
438
+ code_challenge_method: claims["code_challenge_method"],
439
+ scope: claims["scope"],
440
+ expires_at: codeExpiry
441
+ });
442
+ const existing = await trx("directus_oauth_consents").where({
443
+ user: userId,
444
+ client: clientId,
445
+ redirect_uri: redirectUri
446
+ }).first();
447
+ const now = /* @__PURE__ */ new Date();
448
+ if (existing) await trx("directus_oauth_consents").where("id", existing["id"]).update({ date_updated: now });
449
+ else await trx("directus_oauth_consents").insert({
450
+ id: crypto.randomUUID(),
451
+ user: userId,
452
+ client: clientId,
453
+ redirect_uri: redirectUri,
454
+ scope: claims["scope"],
455
+ date_created: now,
456
+ date_updated: now
457
+ });
458
+ });
459
+ return this.buildRedirectUrl(redirectUri, { code: rawCode }, state, issuerUrl);
460
+ }
461
+ /**
462
+ * Exchange an authorization code for tokens (RFC 6749 Section 4.1.3).
463
+ *
464
+ * Called by the `POST /mcp-oauth/token` controller when `grant_type=authorization_code`.
465
+ * The client sends the code it received from the authorization redirect (produced by
466
+ * {@link processDecision}) along with its PKCE code_verifier.
467
+ *
468
+ * Pre-transaction:
469
+ * 1. Param validation (RFC 6749 Section 4.1.3: grant_type, code, redirect_uri, client_id)
470
+ * 2. code_verifier format check (RFC 7636 Section 4.1: unreserved chars, 43-128 length)
471
+ * 3. Code lookup by SHA-256 hash (raw code never stored)
472
+ *
473
+ * Inside transaction:
474
+ * 4. Atomic burn: `UPDATE WHERE used_at IS NULL` (RFC 6749 Section 10.5: single-use codes)
475
+ * 5. Post-burn validations (expiry, client_id, redirect_uri, resource)
476
+ * Failures roll back the burn, restoring the code for retry
477
+ * 6. PKCE S256 verification via timing-safe compare (RFC 7636 Section 4.6)
478
+ * 7. User status check (must still be active)
479
+ * 8. Session + grant creation, replacing any existing grant for the same (client, user)
480
+ *
481
+ * Post-transaction:
482
+ * 9. JWT signed with scope=mcp:access, aud=resource URL (RFC 8707 Section 2)
483
+ * 10. refresh_token = raw session token (client stores it, server only keeps hash)
484
+ *
485
+ * @param params - Token request body (authorization_code grant)
486
+ * @param context - IP and user-agent for session/activity records
487
+ * @returns Token response (RFC 6749 Section 5.1) with access_token, optional refresh_token
488
+ * @throws {OAuthError} `invalid_grant` for code issues, `invalid_target` for resource mismatch
489
+ */
490
+ async exchangeCode(params, context) {
491
+ const { nanoid } = await import("nanoid");
492
+ const env = useEnv();
493
+ const logger = useLogger();
494
+ const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
495
+ params.client_id = resolvedClientId;
496
+ const preAuthClient = await this.resolveClientFromDb(resolvedClientId);
497
+ if (!preAuthClient) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
498
+ this.authenticateClient(preAuthClient, params, basicAuth);
499
+ const tokenEndpointAuthMethod = preAuthClient["token_endpoint_auth_method"];
500
+ const isAuthenticatedConfidentialClient = tokenEndpointAuthMethod === "client_secret_basic" || tokenEndpointAuthMethod === "client_secret_post";
501
+ if (!params.grant_type) throw new OAuthError(400, "invalid_request", "grant_type is required");
502
+ if (params.grant_type !== "authorization_code") throw new OAuthError(400, "unsupported_grant_type", "Only authorization_code grant is supported");
503
+ if (!params.code) throw new OAuthError(400, "invalid_request", "code is required");
504
+ if (!params.redirect_uri) throw new OAuthError(400, "invalid_request", "redirect_uri is required");
505
+ if (!params.code_verifier) throw new OAuthError(400, "invalid_request", "code_verifier is required");
506
+ if (!CODE_VERIFIER_RE.test(params.code_verifier)) throw new OAuthError(400, "invalid_request", "Invalid code_verifier format");
507
+ const codeHash = this.hashToken(params.code);
508
+ const codeRecord = await this.knex("directus_oauth_codes").where({ code_hash: codeHash }).first();
509
+ if (!codeRecord) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
510
+ function rejectCode(logFields, logMessage) {
511
+ logger.warn({
512
+ code_hash: codeHash,
513
+ ...logFields
514
+ }, logMessage);
515
+ throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
516
+ }
517
+ const sessionToken = nanoid(64);
518
+ const sessionHash = this.hashToken(sessionToken);
519
+ const refreshTtl = getMilliseconds(env["REFRESH_TOKEN_TTL"], 0);
520
+ const accessTtl = getMilliseconds(env["ACCESS_TOKEN_TTL"], 0);
521
+ const sessionExpiry = new Date(Date.now() + refreshTtl);
522
+ const grantId = crypto.randomUUID();
523
+ const exchangeResult = await transaction(this.knex, async (trx) => {
524
+ if (await trx("directus_oauth_codes").where({ code_hash: codeHash }).whereNull("used_at").update({ used_at: /* @__PURE__ */ new Date() }) === 0) {
525
+ logger.warn({ code_hash: codeHash }, "Authorization code already used");
526
+ if (isAuthenticatedConfidentialClient) await this.revokeGrantByCodeHash(trx, codeHash, resolvedClientId);
527
+ return { replayed: true };
528
+ }
529
+ if (new Date(codeRecord["expires_at"]) < /* @__PURE__ */ new Date()) rejectCode({}, "Authorization code expired");
530
+ if (codeRecord["client"] !== params.client_id) rejectCode({
531
+ expected: codeRecord["client"],
532
+ got: params.client_id
533
+ }, "client_id mismatch");
534
+ if (codeRecord["redirect_uri"] !== params.redirect_uri) rejectCode({}, "redirect_uri mismatch");
535
+ const resolvedExchangeResource = params.resource || (!env["MCP_OAUTH_REQUIRE_RESOURCE"] ? codeRecord["resource"] : null);
536
+ if (codeRecord["resource"] !== resolvedExchangeResource) {
537
+ logger.warn({
538
+ code_hash: codeHash,
539
+ expected: codeRecord["resource"],
540
+ got: params.resource
541
+ }, "resource mismatch");
542
+ throw new OAuthError(400, "invalid_target", "Authorization code is invalid or has expired");
543
+ }
544
+ const computedChallenge = this.hashToken(params.code_verifier, "base64url");
545
+ const storedChallenge = codeRecord["code_challenge"];
546
+ if (computedChallenge.length !== storedChallenge.length || !crypto.timingSafeEqual(Buffer.from(computedChallenge), Buffer.from(storedChallenge))) rejectCode({}, "PKCE verification failed");
547
+ const client = await this.resolveClientFromDb(params.client_id, trx);
548
+ if (!client) rejectCode({ client_id: params.client_id }, "Unknown client during code exchange");
549
+ const txClientGrantTypes = parseStringArrayField(client["grant_types"], "grant_types");
550
+ const txClientName = client["client_name"];
551
+ const txUserId = codeRecord["user"];
552
+ const { email: txUserEmail, role: txUserRole } = await this.requireActiveUser(txUserId, trx);
553
+ const txScope = codeRecord["scope"] || MCP_ACCESS_SCOPE;
554
+ const txResource = codeRecord["resource"];
555
+ const existingGrant = await trx("directus_oauth_tokens").where({
556
+ client: params.client_id,
557
+ user: txUserId
558
+ }).first();
559
+ if (existingGrant) {
560
+ await trx("directus_oauth_tokens").where({
561
+ client: params.client_id,
562
+ user: txUserId
563
+ }).delete();
564
+ await trx("directus_sessions").where("token", existingGrant.session).delete();
565
+ }
566
+ await trx("directus_sessions").insert({
567
+ token: sessionHash,
568
+ user: txUserId,
569
+ expires: sessionExpiry,
570
+ ip: context.ip,
571
+ user_agent: context.userAgent,
572
+ oauth_client: params.client_id
573
+ });
574
+ await trx("directus_oauth_tokens").insert({
575
+ id: grantId,
576
+ client: params.client_id,
577
+ user: txUserId,
578
+ session: sessionHash,
579
+ resource: txResource,
580
+ code_hash: codeHash,
581
+ scope: txScope,
582
+ expires_at: sessionExpiry,
583
+ date_created: /* @__PURE__ */ new Date()
584
+ });
585
+ return {
586
+ replayed: false,
587
+ clientGrantTypes: txClientGrantTypes,
588
+ clientName: txClientName,
589
+ userEmail: txUserEmail,
590
+ userRole: txUserRole,
591
+ userId: txUserId,
592
+ scope: txScope,
593
+ resource: txResource
594
+ };
595
+ });
596
+ if (exchangeResult.replayed) throw new OAuthError(400, "invalid_grant", "Authorization code is invalid or has expired");
597
+ const { clientGrantTypes, clientName, userEmail, userRole, userId, scope, resource } = exchangeResult;
598
+ const accessToken = await this.issueMcpAccessToken({
599
+ userId,
600
+ role: userRole,
601
+ sessionHash,
602
+ resource,
603
+ accessTtl,
604
+ ip: context.ip
605
+ });
606
+ await this.recordOAuthActivity({
607
+ action: Action.LOGIN,
608
+ userId,
609
+ grantId,
610
+ comment: `OAuth grant issued for client ${clientName} (${scope}) to ${userEmail}`,
611
+ ip: context.ip,
612
+ userAgent: context.userAgent
613
+ });
614
+ logger.info({
615
+ client_id: params.client_id,
616
+ scope,
617
+ resource,
618
+ user_id: userId,
619
+ action: "oauth_token_issued",
620
+ ip: context.ip
621
+ }, "OAuth token issued");
622
+ const includeRefreshToken = clientGrantTypes.includes("refresh_token");
623
+ return {
624
+ access_token: accessToken,
625
+ token_type: "Bearer",
626
+ expires_in: Math.floor(accessTtl / 1e3),
627
+ ...includeRefreshToken ? { refresh_token: sessionToken } : {},
628
+ scope: MCP_ACCESS_SCOPE
629
+ };
630
+ }
631
+ /**
632
+ * Refresh an access token with session rotation (RFC 6749 Section 6).
633
+ *
634
+ * Called by `POST /mcp-oauth/token` when `grant_type=refresh_token`. The client sends
635
+ * the refresh_token it received from {@link exchangeCode} or a previous refresh.
636
+ *
637
+ * Session rotation: hash incoming token, look up grant by `session`, atomically
638
+ * `UPDATE WHERE session=old_hash` to new hash. If 0 rows updated, check `previous_session`
639
+ * for reuse detection -- if found, revoke the entire grant (both grant and session deleted).
640
+ *
641
+ * @param params - Refresh token request body
642
+ * @param context - IP and user-agent for the new session
643
+ * @returns New token response with rotated refresh_token
644
+ * @throws {OAuthError} `invalid_grant` on reuse or expiry, `invalid_target` on resource mismatch
645
+ * @see exchangeCode for initial token issuance
646
+ */
647
+ async refreshToken(params, context) {
648
+ const { nanoid } = await import("nanoid");
649
+ const env = useEnv();
650
+ const logger = useLogger();
651
+ const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
652
+ params.client_id = resolvedClientId;
653
+ if (params.grant_type !== "refresh_token") throw new OAuthError(400, "unsupported_grant_type", "grant_type must be refresh_token");
654
+ if (!params.refresh_token) throw new OAuthError(400, "invalid_request", "refresh_token is required");
655
+ if (env["MCP_OAUTH_REQUIRE_RESOURCE"] === true && !params.resource) throw new OAuthError(400, "invalid_target", "resource is required");
656
+ const client = await this.resolveClientFromDb(params.client_id);
657
+ if (!client) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
658
+ this.authenticateClient(client, params, basicAuth);
659
+ if (!parseStringArrayField(client["grant_types"], "grant_types").includes("refresh_token")) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
660
+ const oldSessionHash = this.hashToken(params.refresh_token);
661
+ const grant = await this.knex("directus_oauth_tokens").where("session", oldSessionHash).first();
662
+ if (!grant) {
663
+ await transaction(this.knex, async (trx) => {
664
+ await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
665
+ });
666
+ throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
667
+ }
668
+ if (grant["client"] !== params.client_id) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
669
+ if (params.scope) {
670
+ const requestedScopes = parseOAuthScope(params.scope);
671
+ const grantedScopes = parseOAuthScope(grant["scope"] || MCP_ACCESS_SCOPE);
672
+ if (!requestedScopes.includes(MCP_ACCESS_SCOPE)) throw new OAuthError(400, "invalid_scope", "Scope must include mcp:access");
673
+ if (requestedScopes.some((scope$1) => !grantedScopes.includes(scope$1))) throw new OAuthError(400, "invalid_scope", "Scope must not include scopes outside the original grant");
674
+ }
675
+ const resolvedRefreshResource = params.resource || (!env["MCP_OAUTH_REQUIRE_RESOURCE"] ? grant["resource"] : null);
676
+ if (grant["resource"] !== resolvedRefreshResource) throw new OAuthError(400, "invalid_target", "resource mismatch");
677
+ if (new Date(grant["expires_at"]) < /* @__PURE__ */ new Date()) throw new OAuthError(400, "invalid_grant", "Refresh token expired");
678
+ const userId = grant["user"];
679
+ const { email, role } = await this.requireActiveUser(userId, this.knex);
680
+ const newSessionToken = nanoid(64);
681
+ const newSessionHash = this.hashToken(newSessionToken);
682
+ const refreshTtl = getMilliseconds(env["REFRESH_TOKEN_TTL"], 0);
683
+ const accessTtl = getMilliseconds(env["ACCESS_TOKEN_TTL"], 0);
684
+ const newExpiry = new Date(Date.now() + refreshTtl);
685
+ const resource = grant["resource"];
686
+ const scope = grant["scope"] || MCP_ACCESS_SCOPE;
687
+ const grantId = grant["id"];
688
+ const clientName = client["client_name"];
689
+ if (!await transaction(this.knex, async (trx) => {
690
+ if (await trx("directus_sessions").where({
691
+ token: oldSessionHash,
692
+ user: userId,
693
+ oauth_client: grant["client"]
694
+ }).delete() === 0) {
695
+ if (await trx("directus_oauth_tokens").where({
696
+ id: grantId,
697
+ session: oldSessionHash,
698
+ client: params.client_id
699
+ }).first("id")) await trx("directus_oauth_tokens").where({
700
+ id: grantId,
701
+ session: oldSessionHash,
702
+ client: params.client_id
703
+ }).delete();
704
+ else await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
705
+ return false;
706
+ }
707
+ if (await trx("directus_oauth_tokens").where({
708
+ id: grantId,
709
+ session: oldSessionHash,
710
+ client: params.client_id
711
+ }).update({
712
+ session: newSessionHash,
713
+ previous_session: oldSessionHash,
714
+ expires_at: newExpiry
715
+ }) === 0) {
716
+ await this.detectReuse(trx, oldSessionHash, params.client_id, logger);
717
+ return false;
718
+ }
719
+ await trx("directus_sessions").insert({
720
+ token: newSessionHash,
721
+ user: userId,
722
+ expires: newExpiry,
723
+ ip: context.ip,
724
+ user_agent: context.userAgent,
725
+ oauth_client: grant["client"]
726
+ });
727
+ return true;
728
+ })) throw new OAuthError(400, "invalid_grant", "Invalid refresh token");
729
+ const accessToken = await this.issueMcpAccessToken({
730
+ userId,
731
+ role,
732
+ sessionHash: newSessionHash,
733
+ resource,
734
+ accessTtl,
735
+ ip: context.ip
736
+ });
737
+ await this.recordOAuthActivity({
738
+ action: Action.UPDATE,
739
+ userId,
740
+ grantId,
741
+ comment: `OAuth token refreshed for client ${clientName} (${scope}) by ${email}`,
742
+ ip: context.ip,
743
+ userAgent: context.userAgent
744
+ });
745
+ logger.info({
746
+ client_id: params.client_id,
747
+ scope,
748
+ resource,
749
+ user_id: userId,
750
+ action: "oauth_token_refreshed",
751
+ ip: context.ip
752
+ }, "OAuth token refreshed");
753
+ return {
754
+ access_token: accessToken,
755
+ token_type: "Bearer",
756
+ expires_in: Math.floor(accessTtl / 1e3),
757
+ refresh_token: newSessionToken,
758
+ scope: MCP_ACCESS_SCOPE
759
+ };
760
+ }
761
+ /**
762
+ * Revoke a refresh token (RFC 7009 Section 2). Idempotent for unknown tokens.
763
+ *
764
+ * Called by `POST /mcp-oauth/revoke`. The client sends its refresh_token to end the session.
765
+ *
766
+ * Client authentication is enforced first (resolveClientId + authenticateClient).
767
+ * Unknown client_id or failed secret verification rejects with 401 invalid_client.
768
+ * Per RFC 7009 Section 2.2, unknown/mismatched tokens return silent 200.
769
+ *
770
+ * @param params - Token, client_id, and optional authorization_header
771
+ * @throws {OAuthError} `invalid_client` if client unknown or auth fails
772
+ * @throws {OAuthError} `invalid_request` if token is missing
773
+ */
774
+ async revokeToken(params) {
775
+ const logger = useLogger();
776
+ const { clientId: resolvedClientId, basicAuth } = this.resolveClientId(params);
777
+ params.client_id = resolvedClientId;
778
+ const client = await this.resolveClientFromDb(resolvedClientId);
779
+ if (!client) throw new OAuthError(401, "invalid_client", "Client authentication failed");
780
+ this.authenticateClient(client, params, basicAuth);
781
+ if (!params.token) throw new OAuthError(400, "invalid_request", "token is required");
782
+ const tokenHash = this.hashToken(params.token);
783
+ const grant = await this.knex("directus_oauth_tokens").where("session", tokenHash).first();
784
+ if (!grant || grant["client"] !== params.client_id) return;
785
+ const grantId = grant["id"];
786
+ const userId = grant["user"];
787
+ const clientName = client["client_name"];
788
+ await transaction(this.knex, async (trx) => {
789
+ await trx("directus_oauth_tokens").where("id", grantId).delete();
790
+ await trx("directus_sessions").where("token", tokenHash).delete();
791
+ });
792
+ const userEmail = (await this.knex("directus_users").where("id", userId).select("email").first())?.email ?? "unknown";
793
+ await this.recordOAuthActivity({
794
+ action: Action.LOGOUT,
795
+ userId,
796
+ grantId,
797
+ comment: `OAuth token revoked for client ${clientName} by ${userEmail}`,
798
+ ip: "system",
799
+ userAgent: "system"
800
+ });
801
+ logger.info({
802
+ client_id: params.client_id,
803
+ user_id: userId,
804
+ action: "oauth_token_revoked"
805
+ }, "OAuth token revoked");
806
+ }
807
+ /**
808
+ * Periodic cleanup of expired/orphaned OAuth data.
809
+ *
810
+ * Called by the `oauth-cleanup` scheduled job (cron: `MCP_OAUTH_CLEANUP_SCHEDULE`).
811
+ *
812
+ * Steps:
813
+ * 1. Expired unused codes
814
+ * 2. Used codes older than 1 hour (kept briefly for replay detection logging)
815
+ * 3. Expired grants + their sessions
816
+ * 4. Orphaned grants (session no longer in directus_sessions)
817
+ * 5. Stale clients in two tiers:
818
+ * a) Never-authorized (no consents, no sessions/grants, older than MCP_OAUTH_CLIENT_UNUSED_TTL)
819
+ * b) Idle authorized (has consents but no sessions/grants, older than MCP_OAUTH_CLIENT_IDLE_TTL; disabled when '0')
820
+ */
821
+ async cleanup() {
822
+ const env = useEnv();
823
+ const now = /* @__PURE__ */ new Date();
824
+ const oneHourAgo = /* @__PURE__ */ new Date(now.getTime() - 3600 * 1e3);
825
+ await this.knex("directus_oauth_codes").where("expires_at", "<", now).whereNull("used_at").delete();
826
+ await this.knex("directus_oauth_codes").whereNotNull("used_at").andWhere("used_at", "<", oneHourAgo).delete();
827
+ const expiredGrants = await this.knex("directus_oauth_tokens").where("expires_at", "<", now).select("id", "session");
828
+ if (expiredGrants.length > 0) {
829
+ const sessionHashes = expiredGrants.map((g) => g.session);
830
+ await this.knex("directus_sessions").whereIn("token", sessionHashes).delete();
831
+ await this.knex("directus_oauth_tokens").whereIn("id", expiredGrants.map((g) => g.id)).delete();
832
+ }
833
+ const orphanedGrants = await this.knex("directus_oauth_tokens").leftJoin("directus_sessions", function() {
834
+ this.on("directus_oauth_tokens.session", "=", "directus_sessions.token");
835
+ }).whereNull("directus_sessions.token").select("directus_oauth_tokens.id");
836
+ if (orphanedGrants.length > 0) await this.knex("directus_oauth_tokens").whereIn("id", orphanedGrants.map((g) => g.id)).delete();
837
+ const unusedTtl = getMilliseconds(env["MCP_OAUTH_CLIENT_UNUSED_TTL"], DEFAULT_UNUSED_CLIENT_TTL_MS);
838
+ const unusedCutoff = new Date(now.getTime() - unusedTtl);
839
+ 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");
840
+ if (neverAuthorizedClients.length > 0) await this.knex("directus_oauth_clients").whereIn("client_id", neverAuthorizedClients.map((c) => c.client_id)).delete();
841
+ const idleTtl = getMilliseconds(env["MCP_OAUTH_CLIENT_IDLE_TTL"], 0);
842
+ if (idleTtl > 0) {
843
+ const idleCutoff = new Date(now.getTime() - idleTtl);
844
+ 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");
845
+ if (idleAuthorizedClients.length > 0) await this.knex("directus_oauth_clients").whereIn("client_id", idleAuthorizedClients.map((c) => c.client_id)).delete();
846
+ }
847
+ }
848
+ /**
849
+ * Resolve a client by ID, handling both DCR (UUID) and CIMD (URL) client IDs.
850
+ * Used ONLY by `validateAuthorization` -- the authorization entry point where
851
+ * CIMD clients are fetched/cached on first contact.
852
+ */
853
+ async resolveClientWithFetch(clientId) {
854
+ const logger = useLogger();
855
+ const type = detectClientIdType(clientId);
856
+ if (type === null) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
857
+ if (type === "dcr") {
858
+ const row = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
859
+ if (!row) throw new OAuthError(400, "invalid_request", "Invalid client_id or redirect_uri");
860
+ return row;
861
+ }
862
+ if (!toBoolean(useEnv()["MCP_OAUTH_CIMD_ENABLED"])) throw new OAuthError(400, "invalid_client", "CIMD client registration is disabled");
863
+ 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");
864
+ const allowedDomains = getAllowedDomains();
865
+ if (allowedDomains.length > 0) {
866
+ const hostname = new URL(clientId).hostname;
867
+ if (!isDomainAllowed(hostname, allowedDomains)) {
868
+ logger.debug({ client_id: clientId }, "CIMD client_id domain not in allowlist");
869
+ throw new OAuthError(400, "invalid_client", "Client not allowed");
870
+ }
871
+ }
872
+ const existing = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
873
+ if (existing) {
874
+ if ((existing["metadata_expires_at"] ? new Date(existing["metadata_expires_at"]).getTime() : 0) > Date.now()) return existing;
875
+ return await this.refreshCimdClient(existing);
876
+ }
877
+ return await this.insertCimdClient(clientId);
878
+ }
879
+ /**
880
+ * Simple DB lookup for a client. Used by `exchangeCode` (with trx!), `refreshToken`,
881
+ * and `revokeToken`. Does NOT gate on CIMD disabled (drain-naturally pattern).
882
+ */
883
+ async resolveClientFromDb(clientId, db = this.knex) {
884
+ return db("directus_oauth_clients").where("client_id", clientId).first();
885
+ }
886
+ /** Base64 character set: A-Z, a-z, 0-9, +, /, = (padding) */
887
+ static BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
888
+ /**
889
+ * Parse an Authorization: Basic header. Returns { clientId, clientSecret }
890
+ * or null if the header is absent or not Basic scheme.
891
+ * Throws OAuthError on malformed Basic header.
892
+ */
893
+ parseBasicAuth(header) {
894
+ if (!header) return null;
895
+ if (header.length < 6 || header.slice(0, 6).toLowerCase() !== "basic ") return null;
896
+ const encoded = header.slice(6).trim();
897
+ if (!McpOAuthService.BASE64_RE.test(encoded)) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: invalid base64");
898
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
899
+ if (decoded.includes("\0")) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: contains null bytes");
900
+ const colonIndex = decoded.indexOf(":");
901
+ if (colonIndex === -1) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: missing colon separator");
902
+ try {
903
+ const clientId = decodeURIComponent(decoded.slice(0, colonIndex).replace(/\+/g, " "));
904
+ const clientSecret = decodeURIComponent(decoded.slice(colonIndex + 1).replace(/\+/g, " "));
905
+ if (!clientId) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: client_id is required");
906
+ if (clientId.includes("\0") || clientSecret.includes("\0")) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: contains null bytes");
907
+ return {
908
+ clientId,
909
+ clientSecret
910
+ };
911
+ } catch (err) {
912
+ if (err instanceof URIError) throw new OAuthError(400, "invalid_request", "Malformed Basic authorization: invalid percent-encoding");
913
+ throw err;
914
+ }
915
+ }
916
+ /**
917
+ * Extract client_id from the request. Method-agnostic -- runs before the client
918
+ * record is loaded. Only the Basic scheme is recognized; other Authorization
919
+ * header schemes are ignored (client_id comes from body only).
920
+ */
921
+ resolveClientId(params) {
922
+ const basicAuth = this.parseBasicAuth(params.authorization_header);
923
+ const headerClientId = basicAuth?.clientId;
924
+ const bodyClientId = params.client_id;
925
+ if (headerClientId && bodyClientId && headerClientId !== bodyClientId) throw new OAuthError(400, "invalid_request", "client_id mismatch between Authorization header and request body");
926
+ const clientId = headerClientId ?? bodyClientId;
927
+ if (!clientId) throw new OAuthError(400, "invalid_request", "client_id is required");
928
+ return {
929
+ clientId,
930
+ basicAuth
931
+ };
932
+ }
933
+ static WWW_AUTH_BASIC = { "WWW-Authenticate": "Basic realm=\"directus\"" };
934
+ /**
935
+ * Enforce that the request matches the client's registered auth method exactly.
936
+ * Accepts pre-parsed Basic auth from resolveClientId to avoid double decoding.
937
+ * If preParsedBasicAuth is not provided, parses from params.authorization_header.
938
+ */
939
+ authenticateClient(clientRecord, params, preParsedBasicAuth) {
940
+ const authMethod = clientRecord["token_endpoint_auth_method"];
941
+ const basicAuth = preParsedBasicAuth !== void 0 ? preParsedBasicAuth : this.parseBasicAuth(params.authorization_header);
942
+ const hasBasicHeader = basicAuth !== null;
943
+ const hasBodySecret = typeof params.client_secret === "string" && params.client_secret.length > 0;
944
+ if (authMethod === "none") {
945
+ if (hasBasicHeader) throw new OAuthError(400, "invalid_request", "Authorization header not allowed for public clients");
946
+ if (hasBodySecret) throw new OAuthError(400, "invalid_request", "client_secret not allowed for public clients");
947
+ return;
948
+ }
949
+ if (authMethod === "client_secret_basic") {
950
+ if (hasBodySecret) throw new OAuthError(400, "invalid_request", "client_secret in body not allowed for client_secret_basic");
951
+ if (!hasBasicHeader) throw new OAuthError(401, "invalid_client", "Authorization header required for client_secret_basic", false, McpOAuthService.WWW_AUTH_BASIC);
952
+ this.verifySecret(basicAuth.clientSecret, clientRecord, McpOAuthService.WWW_AUTH_BASIC);
953
+ return;
954
+ }
955
+ if (authMethod === "client_secret_post") {
956
+ if (hasBasicHeader) throw new OAuthError(400, "invalid_request", "Authorization header not allowed for client_secret_post");
957
+ if (!hasBodySecret) throw new OAuthError(401, "invalid_client", "client_secret required for client_secret_post");
958
+ this.verifySecret(params.client_secret, clientRecord);
959
+ return;
960
+ }
961
+ throw new OAuthError(401, "invalid_client", "Unsupported authentication method");
962
+ }
963
+ /** Timing-safe secret verification with hex format guard and length pre-check. */
964
+ verifySecret(providedSecret, clientRecord, errorHeaders = {}) {
965
+ const storedHash = clientRecord["client_secret_hash"];
966
+ if (!storedHash || !SHA256_HEX_RE.test(storedHash)) throw new OAuthError(401, "invalid_client", "Client authentication failed", false, errorHeaders);
967
+ const computedHash = this.hashToken(providedSecret);
968
+ const hashA = Buffer.from(computedHash, "hex");
969
+ const hashB = Buffer.from(storedHash, "hex");
970
+ if (hashA.length !== hashB.length || !crypto.timingSafeEqual(hashA, hashB)) throw new OAuthError(401, "invalid_client", "Client authentication failed", false, errorHeaders);
971
+ }
972
+ /**
973
+ * First contact: fetch CIMD metadata, INSERT new client row.
974
+ * Handles concurrent inserts via unique constraint catch + SELECT fallback.
975
+ */
976
+ async insertCimdClient(clientId) {
977
+ const env = useEnv();
978
+ const logger = useLogger();
979
+ const parsed = Number(env["MCP_OAUTH_MAX_CLIENTS"]);
980
+ const maxClients = Number.isNaN(parsed) ? 1e4 : parsed;
981
+ if (maxClients > 0) {
982
+ const [{ count }] = await this.knex("directus_oauth_clients").count("* as count");
983
+ if (Number(count) >= maxClients) throw new OAuthError(400, "invalid_client", "Maximum number of registered clients reached");
984
+ }
985
+ const result = await fetchCimdMetadata(clientId);
986
+ if (result.notModified || !result.metadata) throw new OAuthError(400, "invalid_client_metadata", "Unexpected 304 on first contact");
987
+ const metadata = result.metadata;
988
+ const now = /* @__PURE__ */ new Date();
989
+ const ttlMs = result.ttlMs ?? DEFAULT_CIMD_TTL_MS;
990
+ const expiresAt = ttlMs > 0 ? new Date(now.getTime() + ttlMs) : now;
991
+ const row = {
992
+ client_id: metadata.client_id,
993
+ client_name: metadata.client_name,
994
+ redirect_uris: JSON.stringify(metadata.redirect_uris),
995
+ grant_types: JSON.stringify(metadata.grant_types),
996
+ token_endpoint_auth_method: metadata.token_endpoint_auth_method,
997
+ registration_type: "cimd",
998
+ client_uri: metadata.client_uri ?? null,
999
+ logo_uri: metadata.logo_uri ?? null,
1000
+ tos_uri: metadata.tos_uri ?? null,
1001
+ policy_uri: metadata.policy_uri ?? null,
1002
+ metadata_fetched_at: now,
1003
+ metadata_expires_at: expiresAt,
1004
+ metadata_etag: result.etag ?? null
1005
+ };
1006
+ try {
1007
+ await this.knex("directus_oauth_clients").insert(row);
1008
+ logger.info({ client_id: clientId }, "CIMD client registered");
1009
+ return row;
1010
+ } catch (err) {
1011
+ if (await translateDatabaseError(err, row) instanceof RecordNotUniqueError) {
1012
+ logger.debug({ client_id: clientId }, "CIMD concurrent insert, selecting existing");
1013
+ const existing = await this.knex("directus_oauth_clients").where("client_id", clientId).first();
1014
+ if (!existing) throw new OAuthError(400, "invalid_client", "Failed to register CIMD client");
1015
+ return existing;
1016
+ }
1017
+ throw err;
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Stale cache: re-fetch CIMD metadata with conditional request support.
1022
+ * On 304: recompute TTL and update timestamps.
1023
+ * On 200: validate and update full row.
1024
+ * On failure: block request (don't serve stale).
1025
+ */
1026
+ async refreshCimdClient(existing) {
1027
+ const logger = useLogger();
1028
+ const clientId = existing["client_id"];
1029
+ const storedEtag = existing["metadata_etag"];
1030
+ try {
1031
+ const result = await fetchCimdMetadata(clientId, storedEtag ?? void 0);
1032
+ if (result.notModified) {
1033
+ const now = /* @__PURE__ */ new Date();
1034
+ let newTtlMs;
1035
+ if (result.ttlMs !== null) newTtlMs = result.ttlMs;
1036
+ else {
1037
+ 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;
1038
+ newTtlMs = prevTtl !== null && prevTtl >= 0 ? prevTtl : DEFAULT_CIMD_TTL_MS;
1039
+ }
1040
+ const expiresAt = newTtlMs > 0 ? new Date(now.getTime() + newTtlMs) : now;
1041
+ await this.knex("directus_oauth_clients").where("client_id", clientId).update({
1042
+ metadata_fetched_at: now,
1043
+ metadata_expires_at: expiresAt
1044
+ });
1045
+ logger.debug({ client_id: clientId }, "CIMD metadata revalidated (304)");
1046
+ return {
1047
+ ...existing,
1048
+ metadata_fetched_at: now,
1049
+ metadata_expires_at: expiresAt
1050
+ };
1051
+ }
1052
+ return await this.updateCimdClient(existing, result.metadata, result.etag ?? null, result.ttlMs ?? DEFAULT_CIMD_TTL_MS);
1053
+ } catch (err) {
1054
+ logger.warn({
1055
+ client_id: clientId,
1056
+ err: err instanceof Error ? err.message : err
1057
+ }, "CIMD re-fetch failed");
1058
+ if (err instanceof OAuthError) throw err;
1059
+ throw new OAuthError(400, "invalid_client", "Failed to revalidate client metadata");
1060
+ }
1061
+ }
1062
+ /**
1063
+ * UPDATE all metadata columns + cache timestamps for a CIMD client.
1064
+ */
1065
+ async updateCimdClient(existing, metadata, etag, ttlMs) {
1066
+ const logger = useLogger();
1067
+ const now = /* @__PURE__ */ new Date();
1068
+ const expiresAt = ttlMs > 0 ? new Date(now.getTime() + ttlMs) : now;
1069
+ const updates = {
1070
+ client_name: metadata.client_name,
1071
+ redirect_uris: JSON.stringify(metadata.redirect_uris),
1072
+ grant_types: JSON.stringify(metadata.grant_types),
1073
+ token_endpoint_auth_method: metadata.token_endpoint_auth_method,
1074
+ client_uri: metadata.client_uri ?? null,
1075
+ logo_uri: metadata.logo_uri ?? null,
1076
+ tos_uri: metadata.tos_uri ?? null,
1077
+ policy_uri: metadata.policy_uri ?? null,
1078
+ metadata_fetched_at: now,
1079
+ metadata_expires_at: expiresAt,
1080
+ metadata_etag: etag
1081
+ };
1082
+ await this.knex("directus_oauth_clients").where("client_id", existing["client_id"]).update(updates);
1083
+ logger.info({ client_id: existing["client_id"] }, "CIMD metadata refreshed");
1084
+ return {
1085
+ ...existing,
1086
+ ...updates
1087
+ };
1088
+ }
1089
+ buildRedirectUrl(redirectUri, params, state, issuerUrl) {
1090
+ const url = new URL(redirectUri);
1091
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
1092
+ if (state) url.searchParams.set("state", state);
1093
+ url.searchParams.set("iss", issuerUrl);
1094
+ return url.toString();
1095
+ }
1096
+ async issueMcpAccessToken(opts) {
1097
+ const rolesTree = await fetchRolesTree(opts.role, { knex: this.knex });
1098
+ const globalAccess = await fetchGlobalAccess({
1099
+ user: opts.userId,
1100
+ roles: rolesTree,
1101
+ ip: opts.ip
1102
+ }, { knex: this.knex });
1103
+ return jwt.sign({
1104
+ id: opts.userId,
1105
+ role: rolesTree[0] ?? null,
1106
+ app_access: globalAccess.app,
1107
+ admin_access: globalAccess.admin,
1108
+ session: opts.sessionHash,
1109
+ scope: MCP_ACCESS_SCOPE,
1110
+ aud: opts.resource
1111
+ }, getSecret(), {
1112
+ expiresIn: opts.accessTtl / 1e3,
1113
+ issuer: "directus"
1114
+ });
1115
+ }
1116
+ async recordOAuthActivity(opts) {
1117
+ await new ActivityService({
1118
+ knex: this.knex,
1119
+ schema: this.schema
1120
+ }).createOne({
1121
+ action: opts.action,
1122
+ user: opts.userId,
1123
+ collection: "directus_oauth_tokens",
1124
+ item: opts.grantId,
1125
+ comment: opts.comment,
1126
+ ip: opts.ip,
1127
+ user_agent: opts.userAgent
1128
+ });
1129
+ }
1130
+ async requireActiveUser(userId, knex) {
1131
+ const userRecord = await knex("directus_users").where("id", userId).select("email", "status", "role").first();
1132
+ if (!userRecord || userRecord.status !== "active") throw new OAuthError(400, "invalid_grant", "User account is not active");
1133
+ return {
1134
+ email: userRecord.email ?? "unknown",
1135
+ role: userRecord.role ?? null
1136
+ };
1137
+ }
1138
+ /** Detect and revoke grants where previous_session matches (refresh token reuse). */
1139
+ async detectReuse(db, oldSessionHash, clientId, logger) {
1140
+ const reuseGrant = await db("directus_oauth_tokens").where({
1141
+ previous_session: oldSessionHash,
1142
+ client: clientId
1143
+ }).first();
1144
+ if (reuseGrant) {
1145
+ await db("directus_oauth_tokens").where({
1146
+ id: reuseGrant["id"],
1147
+ previous_session: oldSessionHash,
1148
+ client: clientId
1149
+ }).delete();
1150
+ await db("directus_sessions").where({
1151
+ token: reuseGrant["session"],
1152
+ user: reuseGrant["user"],
1153
+ oauth_client: clientId
1154
+ }).delete();
1155
+ logger.warn({
1156
+ client_id: clientId,
1157
+ grant_id: reuseGrant["id"]
1158
+ }, "Refresh token reuse detected, grant revoked");
1159
+ }
1160
+ }
1161
+ /** Detect and revoke a grant issued from a replayed authorization code. */
1162
+ async revokeGrantByCodeHash(db, codeHash, clientId) {
1163
+ const replayGrant = await db("directus_oauth_tokens").where({
1164
+ code_hash: codeHash,
1165
+ client: clientId
1166
+ }).first();
1167
+ if (replayGrant) {
1168
+ await db("directus_oauth_tokens").where("id", replayGrant["id"]).delete();
1169
+ await db("directus_sessions").where("token", replayGrant["session"]).delete();
1170
+ }
1171
+ }
1172
+ /** HMAC-SHA256 key derived from SECRET for signing/verifying consent JWTs. Domain-separated to prevent token confusion. */
1173
+ getConsentKey() {
1174
+ return crypto.createHmac("sha256", getSecret()).update("mcp-oauth-consent-v1").digest();
1175
+ }
1176
+ hashToken(token, encoding = "hex") {
1177
+ return crypto.createHash("sha256").update(token).digest(encoding);
1178
+ }
1179
+ validateRedirectUri(uri) {
1180
+ validateRedirectUri(uri);
1181
+ }
1182
+ };
1183
+
1184
+ //#endregion
1185
+ export { McpOAuthService, OAuthError, isDomainAllowed, isLoopbackHost, validateRedirectUri };