@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.
- package/dist/ai/chat/models/chat-request.js +48 -48
- package/dist/ai/chat/models/object-request.js +6 -6
- package/dist/ai/chat/models/providers.js +14 -14
- package/dist/ai/chat/utils/parse-json-schema-7.js +22 -22
- package/dist/ai/mcp/server.js +44 -6
- package/dist/ai/mcp/utils.js +31 -0
- package/dist/ai/tools/assets/index.js +3 -3
- package/dist/ai/tools/collections/index.js +18 -18
- package/dist/ai/tools/fields/index.js +18 -18
- package/dist/ai/tools/files/index.js +18 -18
- package/dist/ai/tools/flows/index.js +16 -16
- package/dist/ai/tools/folders/index.js +18 -18
- package/dist/ai/tools/items/index.js +17 -17
- package/dist/ai/tools/operations/index.js +16 -16
- package/dist/ai/tools/relations/index.js +22 -22
- package/dist/ai/tools/schema/index.js +3 -3
- package/dist/ai/tools/schema.js +159 -159
- package/dist/ai/tools/system/index.js +3 -3
- package/dist/ai/tools/trigger-flow/index.js +3 -3
- package/dist/app.js +33 -9
- package/dist/auth/drivers/ldap.js +3 -1
- package/dist/auth/drivers/local.js +2 -0
- package/dist/auth/drivers/oauth2.js +3 -1
- package/dist/auth/drivers/openid.js +3 -1
- package/dist/auth/drivers/saml.js +2 -0
- package/dist/auth/utils/check-local-disabled.js +16 -0
- package/dist/auth/utils/check-sso-enabled.js +14 -0
- package/dist/auth.js +8 -5
- package/dist/cli/commands/bootstrap/index.js +3 -0
- package/dist/cli/commands/cache/clear.js +6 -1
- package/dist/cli/commands/roles/create.js +4 -1
- package/dist/cli/commands/users/create.js +3 -0
- package/dist/constants.js +8 -1
- package/dist/controllers/access.js +1 -1
- package/dist/controllers/activity.js +2 -1
- package/dist/controllers/assets.js +2 -0
- package/dist/controllers/auth.js +13 -5
- package/dist/controllers/collections.js +1 -1
- package/dist/controllers/comments.js +1 -1
- package/dist/controllers/dashboards.js +1 -1
- package/dist/controllers/fields.js +1 -1
- package/dist/controllers/files.js +3 -1
- package/dist/controllers/flows.js +6 -5
- package/dist/controllers/folders.js +1 -1
- package/dist/controllers/graphql.js +2 -0
- package/dist/controllers/items.js +3 -1
- package/dist/controllers/license.js +119 -0
- package/dist/controllers/mcp/index.js +38 -0
- package/dist/controllers/mcp/oauth-clients.js +68 -0
- package/dist/controllers/mcp/oauth-consent-page.js +316 -0
- package/dist/controllers/mcp/oauth.js +381 -0
- package/dist/controllers/mcp/templates/oauth-consent.liquid +62 -0
- package/dist/controllers/mcp/templates/oauth-error.liquid +28 -0
- package/dist/controllers/notifications.js +1 -1
- package/dist/controllers/operations.js +1 -1
- package/dist/controllers/panels.js +1 -1
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/policies.js +1 -1
- package/dist/controllers/presets.js +1 -1
- package/dist/controllers/revisions.js +3 -2
- package/dist/controllers/roles.js +1 -1
- package/dist/controllers/server.js +38 -9
- package/dist/controllers/shares.js +1 -1
- package/dist/controllers/translations.js +1 -1
- package/dist/controllers/users.js +1 -1
- package/dist/controllers/utils.js +2 -2
- package/dist/controllers/versions.js +12 -5
- package/dist/database/get-ast-from-query/lib/convert-wildcards.js +10 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -1
- package/dist/database/helpers/fn/dialects/mysql.js +7 -12
- package/dist/database/helpers/fn/dialects/oracle.js +3 -4
- package/dist/database/helpers/fn/dialects/postgres.js +4 -26
- package/dist/database/helpers/fn/json/mysql-json-path.js +22 -0
- package/dist/database/helpers/fn/json/parse-function.js +14 -6
- package/dist/database/helpers/fn/json/postgres-json-path.js +54 -0
- package/dist/database/migrations/20260110A-add-ai-provider-settings.js +4 -4
- package/dist/database/migrations/20260217A-null-item-versions.js +14 -0
- package/dist/database/migrations/20260312A-add-ai-translation-settings.js +18 -0
- package/dist/database/migrations/20260507A-add-licensing.js +22 -0
- package/dist/database/migrations/20260512A-add-autosave-revision-interval.js +14 -0
- package/dist/database/migrations/20260512B-add-mcp-oauth.js +87 -0
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +116 -33
- package/dist/database/run-ast/lib/apply-query/index.js +4 -1
- package/dist/database/run-ast/lib/apply-query/sort.js +17 -7
- package/dist/database/run-ast/lib/get-db-query.js +21 -9
- package/dist/database/run-ast/lib/parse-current-level.js +2 -1
- package/dist/database/run-ast/run-ast.js +2 -1
- package/dist/database/run-ast/utils/get-column.js +2 -1
- package/dist/extensions/lib/installation/manager.js +1 -1
- package/dist/extensions/lib/sandbox/register/operation.js +1 -1
- package/dist/extensions/lib/sync/sync.js +1 -1
- package/dist/extensions/manager.js +3 -3
- package/dist/flows.js +5 -5
- package/dist/license/entitlements/lib/collections.js +37 -0
- package/dist/license/entitlements/lib/custom-llms-enabled.js +18 -0
- package/dist/license/entitlements/lib/custom-permission-rules-enabled.js +41 -0
- package/dist/license/entitlements/lib/flows.js +29 -0
- package/dist/license/entitlements/lib/seats.js +103 -0
- package/dist/license/entitlements/lib/sso-enabled.js +45 -0
- package/dist/license/entitlements/manager.js +256 -0
- package/dist/license/index.js +4 -0
- package/dist/license/manager.js +505 -0
- package/dist/license/utils/compute-license-status.js +27 -0
- package/dist/license/utils/get-core-grace-expires-at.js +38 -0
- package/dist/license/utils/get-license-key.js +23 -0
- package/dist/license/utils/get-license-token.js +23 -0
- package/dist/license/utils/handle-license-error.js +41 -0
- package/dist/license/utils/is-in-core-grace-period.js +11 -0
- package/dist/license/utils/is-sso-bypass-allowed.js +21 -0
- package/dist/license/utils/use-rpc.js +33 -0
- package/dist/middleware/cache.js +4 -1
- package/dist/middleware/error-handler.js +11 -0
- package/dist/middleware/extract-token.js +11 -2
- package/dist/middleware/is-admin.js +16 -0
- package/dist/middleware/is-locked.js +16 -0
- package/dist/middleware/mcp-oauth-guard.js +23 -0
- package/dist/middleware/request-counter.js +5 -2
- package/dist/packages/types/dist/index.js +117 -122
- package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +10 -1
- package/dist/permissions/utils/get-unaliased-field-key.js +2 -1
- package/dist/request/is-denied-ip.js +2 -0
- package/dist/schedules/license.js +31 -0
- package/dist/schedules/oauth-cleanup.js +26 -0
- package/dist/schedules/retention.js +1 -1
- package/dist/schedules/telemetry.js +4 -1
- package/dist/schedules/tus.js +1 -1
- package/dist/schedules/utils/duration-to-cron.js +36 -0
- package/dist/services/activity.js +15 -0
- package/dist/services/authentication.js +12 -5
- package/dist/services/collections.js +40 -10
- package/dist/services/fields.js +6 -6
- package/dist/services/flows.js +12 -0
- package/dist/services/graphql/resolvers/system-admin.js +2 -2
- package/dist/services/graphql/resolvers/system-global.js +1 -1
- package/dist/services/graphql/resolvers/system.js +34 -18
- package/dist/services/graphql/schema/get-types.js +23 -2
- package/dist/services/graphql/schema/parse-query.js +8 -0
- package/dist/services/graphql/schema/read.js +12 -0
- package/dist/services/graphql/types/json-filter.js +30 -0
- package/dist/services/index.js +6 -6
- package/dist/services/items.js +32 -14
- package/dist/services/mcp-oauth/cimd.js +307 -0
- package/dist/services/mcp-oauth/index.js +1185 -0
- package/dist/services/mcp-oauth/types/error.js +22 -0
- package/dist/services/mcp-oauth/utils/cimd-egress.js +182 -0
- package/dist/services/mcp-oauth/utils/domain.js +21 -0
- package/dist/services/mcp-oauth/utils/loopback.js +11 -0
- package/dist/services/mcp-oauth/utils/redirect.js +84 -0
- package/dist/services/mcp-oauth/utils/registration-debug.js +131 -0
- package/dist/services/payload.js +2 -1
- package/dist/services/permissions.js +31 -9
- package/dist/services/revisions.js +15 -0
- package/dist/services/server.js +21 -4
- package/dist/services/settings.js +37 -3
- package/dist/services/users.js +13 -6
- package/dist/services/utils.js +6 -1
- package/dist/services/versions.js +137 -69
- package/dist/utils/calculate-field-depth.js +1 -0
- package/dist/utils/deep-freeze.js +24 -0
- package/dist/utils/extract-function-name.js +13 -0
- package/dist/utils/generate-translations.js +5 -5
- package/dist/utils/get-accountability-for-token.js +13 -1
- package/dist/utils/get-cache-key.js +1 -1
- package/dist/utils/get-history-filter-query.js +22 -0
- package/dist/utils/get-schema.js +2 -2
- package/dist/utils/get-service.js +3 -3
- package/dist/utils/is-admin.js +9 -0
- package/dist/utils/parse-oauth-scope.js +12 -0
- package/dist/utils/sanitize-query.js +1 -1
- package/dist/utils/split-field-path.js +29 -0
- package/dist/utils/transaction.js +2 -2
- package/dist/utils/translations-validation.js +2 -2
- package/dist/utils/validate-query.js +35 -4
- package/dist/utils/validate-user-count-integrity.js +28 -5
- package/dist/utils/verify-session-jwt.js +5 -2
- package/dist/utils/versioning/handle-version.js +130 -48
- package/dist/utils/versioning/remove-circular.js +17 -0
- package/dist/websocket/authenticate.js +2 -1
- package/dist/websocket/collab/collab.js +1 -1
- package/dist/websocket/collab/room.js +1 -1
- package/dist/websocket/controllers/base.js +12 -0
- package/dist/websocket/controllers/graphql.js +1 -1
- package/dist/websocket/handlers/subscribe.js +1 -1
- package/dist/websocket/messages.js +64 -64
- package/dist/websocket/utils/items.js +2 -2
- package/license +90 -80
- package/package.json +33 -32
- 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 };
|