From 2c4d86adc178477db8ea4ed2b4de03832991450e Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:19:21 +0100 Subject: [PATCH] feat(mcp): handle server oauth (#7585) * draft * better * more compliant * better frontend * proxy well known to backend * make authenticate layer work * correctly scoped * cleaning * cleaning * cleaning * better * update sqlx * cleaning * better frontend * add missing param * deprecate /sse for /mcp * handle refresh token * cleaning * update sqlx * cleaning * cleaning * remove grants --- ...8c96ce686fce217be21ec15a8900b772f02a3.json | 21 + ...6e3f6ca4961b68d7e409386e655a61c54e0d0.json | 23 + ...7d6fd220659dcd9e16d4bb110202321bc06cf.json | 34 + ...50cc6b4df813a15c613eb1960eff202c517f8.json | 21 + ...8fc55a0113b4156274d03d5af643c3da73fa4.json | 64 ++ ...79b4c3e6788ba521ef5736f46d6ec8447ad8f.json | 20 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...1168573162745060788fbca188ed405c423cd.json | 89 ++ ...0e58416d59a9a855afd1ec0a814937583461f.json | 14 + ...c4918958244abd7a1f81f32a0e60f7e5f9464.json | 16 + ...3821cf73ace9d6692750ccc4cca37701ed77a.json | 14 + ...826d92b9808c64ef989fef2142bc8e02d6630.json | 19 + ...00aea034d7ee2c86aa8fa5ccec0e3560965fd.json | 22 + .../20260120100000_mcp_oauth_server.down.sql | 3 + .../20260120100000_mcp_oauth_server.up.sql | 44 + backend/src/monitor.rs | 33 + backend/windmill-api/src/lib.rs | 76 +- backend/windmill-api/src/mcp/core.rs | 47 + backend/windmill-api/src/mcp/mod.rs | 6 +- backend/windmill-api/src/mcp/oauth_server.rs | 840 ++++++++++++++++++ .../components/settings/CreateToken.svelte | 2 +- .../(logged)/oauth/mcp_authorize/+page.svelte | 150 ++++ frontend/vite.config.js | 5 + 23 files changed, 1556 insertions(+), 9 deletions(-) create mode 100644 backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json create mode 100644 backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json create mode 100644 backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json create mode 100644 backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json create mode 100644 backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json create mode 100644 backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json create mode 100644 backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json create mode 100644 backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json create mode 100644 backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json create mode 100644 backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json create mode 100644 backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json create mode 100644 backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json create mode 100644 backend/migrations/20260120100000_mcp_oauth_server.down.sql create mode 100644 backend/migrations/20260120100000_mcp_oauth_server.up.sql create mode 100644 backend/windmill-api/src/mcp/oauth_server.rs create mode 100644 frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte diff --git a/backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json b/backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json new file mode 100644 index 0000000000..8dacf0b647 --- /dev/null +++ b/backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mcp_oauth_server_code\n (code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "TextArray", + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3" +} diff --git a/backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json b/backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json new file mode 100644 index 0000000000..e91e8619e1 --- /dev/null +++ b/backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0" +} diff --git a/backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json b/backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json new file mode 100644 index 0000000000..97403d1f9f --- /dev/null +++ b/backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "client_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "client_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "redirect_uris", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf" +} diff --git a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json b/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json new file mode 100644 index 0000000000..32e0372293 --- /dev/null +++ b/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "TextArray", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8" +} diff --git a/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json new file mode 100644 index 0000000000..501e149fc4 --- /dev/null +++ b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mcp_oauth_server_code\n WHERE code = $1 AND expires_at > now()\n RETURNING code, client_id, user_email, workspace_id, scopes, redirect_uri,\n code_challenge, code_challenge_method", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "client_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "user_email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "code_challenge", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "code_challenge_method", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4" +} diff --git a/backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json b/backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json new file mode 100644 index 0000000000..99db524414 --- /dev/null +++ b/backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mcp_oauth_server_code WHERE expires_at <= now() RETURNING code", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json b/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json new file mode 100644 index 0000000000..63478d0e66 --- /dev/null +++ b/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json @@ -0,0 +1,89 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "refresh_token", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "access_token", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "client_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "user_email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "token_family", + "type_info": "Uuid" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd" +} diff --git a/backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json b/backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json new file mode 100644 index 0000000000..8ae723683d --- /dev/null +++ b/backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mcp_oauth_refresh_token SET revoked = TRUE WHERE token_family = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f" +} diff --git a/backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json b/backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json new file mode 100644 index 0000000000..f17baeb4cc --- /dev/null +++ b/backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mcp_oauth_server_client (client_id, client_name, redirect_uris)\n VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464" +} diff --git a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json b/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json new file mode 100644 index 0000000000..3d63bbcfbf --- /dev/null +++ b/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a" +} diff --git a/backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json b/backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json new file mode 100644 index 0000000000..37c73fbbc3 --- /dev/null +++ b/backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630" +} diff --git a/backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json b/backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json new file mode 100644 index 0000000000..b4594c1768 --- /dev/null +++ b/backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token_family FROM mcp_oauth_refresh_token\n WHERE refresh_token = $1 AND used_at IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_family", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd" +} diff --git a/backend/migrations/20260120100000_mcp_oauth_server.down.sql b/backend/migrations/20260120100000_mcp_oauth_server.down.sql new file mode 100644 index 0000000000..10f7a0ecf5 --- /dev/null +++ b/backend/migrations/20260120100000_mcp_oauth_server.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS mcp_oauth_refresh_token; +DROP TABLE IF EXISTS mcp_oauth_server_code; +DROP TABLE IF EXISTS mcp_oauth_server_client; diff --git a/backend/migrations/20260120100000_mcp_oauth_server.up.sql b/backend/migrations/20260120100000_mcp_oauth_server.up.sql new file mode 100644 index 0000000000..888c1c0f76 --- /dev/null +++ b/backend/migrations/20260120100000_mcp_oauth_server.up.sql @@ -0,0 +1,44 @@ +-- OAuth server: clients that have registered with Windmill to access MCP +-- Only public clients are supported (PKCE required, no client secrets) +CREATE TABLE mcp_oauth_server_client ( + client_id VARCHAR(255) PRIMARY KEY, + client_name VARCHAR(255) NOT NULL, + redirect_uris TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- OAuth server: authorization codes (short-lived, single-use) +CREATE TABLE mcp_oauth_server_code ( + code VARCHAR(64) PRIMARY KEY, + client_id VARCHAR(255) NOT NULL REFERENCES mcp_oauth_server_client(client_id) ON DELETE CASCADE, + user_email VARCHAR(255) NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + scopes TEXT[] NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge VARCHAR(128), -- PKCE + code_challenge_method VARCHAR(10), -- 'S256' or 'plain' + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '10 minutes' +); + +CREATE INDEX idx_mcp_oauth_server_code_expires ON mcp_oauth_server_code(expires_at); + +-- MCP OAuth refresh tokens for token rotation +CREATE TABLE mcp_oauth_refresh_token ( + id BIGSERIAL PRIMARY KEY, + refresh_token VARCHAR(64) NOT NULL UNIQUE, + access_token VARCHAR(64) NOT NULL, + client_id VARCHAR(255) NOT NULL REFERENCES mcp_oauth_server_client(client_id) ON DELETE CASCADE, + user_email VARCHAR(255) NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + scopes TEXT[] NOT NULL, + token_family UUID NOT NULL, -- Groups tokens from same auth flow for theft detection + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ DEFAULT NULL, -- For rotation tracking (single-use) + revoked BOOLEAN NOT NULL DEFAULT FALSE -- For theft detection +); + +CREATE INDEX idx_mcp_oauth_refresh_token_token ON mcp_oauth_refresh_token(refresh_token); +CREATE INDEX idx_mcp_oauth_refresh_token_expires ON mcp_oauth_refresh_token(expires_at); +CREATE INDEX idx_mcp_oauth_refresh_token_family ON mcp_oauth_refresh_token(token_family); \ No newline at end of file diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 055feee0c2..2fd9983e79 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -859,6 +859,22 @@ pub async fn delete_expired_items(db: &DB) -> () { Err(e) => tracing::error!("Error deleting pip_resolution: {}", e.to_string()), } + // Clean up expired MCP OAuth refresh tokens + let mcp_refresh_tokens_r: std::result::Result, _> = sqlx::query_scalar( + "DELETE FROM mcp_oauth_refresh_token WHERE expires_at <= now() RETURNING id", + ) + .fetch_all(db) + .await; + + match mcp_refresh_tokens_r { + Ok(ids) => { + if ids.len() > 0 { + tracing::info!("deleted {} expired MCP OAuth refresh tokens", ids.len()) + } + } + Err(e) => tracing::error!("Error deleting MCP OAuth refresh tokens: {}", e.to_string()), + } + let deleted_cache = sqlx::query_scalar!( "DELETE FROM resource WHERE resource_type = 'cache' AND to_timestamp((value->>'expire')::int) < now() RETURNING path", ) @@ -952,6 +968,23 @@ pub async fn delete_expired_items(db: &DB) -> () { Err(e) => tracing::error!("Error deleting expired blacklisted agent tokens: {:?}", e), } + match sqlx::query_scalar!( + "DELETE FROM mcp_oauth_server_code WHERE expires_at <= now() RETURNING code", + ) + .fetch_all(db) + .await + { + Ok(deleted_codes) => { + if deleted_codes.len() > 0 { + tracing::info!( + "deleted {} expired MCP OAuth authorization codes", + deleted_codes.len() + ); + } + } + Err(e) => tracing::error!("Error deleting expired MCP OAuth authorization codes: {:?}", e), + } + let job_retention_secs = *JOB_RETENTION_SECS.read().await; if job_retention_secs > 0 { let batch_size = *JOB_CLEANUP_BATCH_SIZE; diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 89be41314b..6da5289705 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -414,13 +414,15 @@ pub async fn run_server( let (mcp_router, mcp_cancellation_token) = { #[cfg(feature = "mcp")] if server_mode || mcp_mode { + use mcp::add_www_authenticate_header; let (mcp_router, mcp_cancellation_token) = setup_mcp_server(db.clone(), user_db).await?; - let mcp_middleware = axum::middleware::from_fn(extract_and_store_workspace_id); - ( - mcp_router.layer(mcp_middleware), - Some(mcp_cancellation_token), - ) + // Apply middleware: auth check inside WWW-Authenticate wrapper so 401s get the header + let mcp_router = mcp_router + .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(add_www_authenticate_header)) + .layer(axum::middleware::from_fn(extract_and_store_workspace_id)); + (mcp_router, Some(mcp_cancellation_token)) } else { (Router::new(), None) } @@ -500,6 +502,16 @@ pub async fn run_server( #[cfg(not(feature = "oauth2"))] Router::new() }) + .nest("/mcp/oauth/server", { + #[cfg(feature = "mcp")] + { + // Only /approve requires authentication (called by frontend) + mcp::oauth_server::workspaced_authed_service() + } + + #[cfg(not(feature = "mcp"))] + Router::new() + }) .nest("/ai", ai::workspaced_service()) .nest("/npm_proxy", npm_proxy::workspaced_service()) .nest("/raw_apps", raw_apps::workspaced_service()) @@ -545,10 +557,20 @@ pub async fn run_server( .nest("/embeddings", embeddings::global_service()) .nest("/ai", ai::global_service()) .nest("/inkeep", inkeep_oss::global_service()) - .nest("/mcp/w/:workspace_id/sse", mcp_router) .nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service) .route_layer(from_extractor::()) .route_layer(from_extractor::()) + // Workspace-scoped OAuth endpoints that don't require authentication + // (authorize and token are called by MCP client before user is authenticated) + .nest("/w/:workspace_id/mcp/oauth/server", { + #[cfg(feature = "mcp")] + { + mcp::oauth_server::workspaced_unauthed_service() + } + + #[cfg(not(feature = "mcp"))] + Router::new() + }) .nest("/jobs", jobs::global_root_service()) .nest( "/srch/w/:workspace_id/index", @@ -587,6 +609,9 @@ pub async fn run_server( .layer(cors.clone()), ) .layer(from_extractor::()) + // Deprecated, here for backwards compatibility: user should use /mcp/w/:workspace_id/mcp instead + .nest("/mcp/w/:workspace_id/sse", mcp_router.clone()) + .nest("/mcp/w/:workspace_id/mcp", mcp_router) .nest("/agent_workers", { #[cfg(feature = "agent_worker_server")] { @@ -689,6 +714,15 @@ pub async fn run_server( #[cfg(not(feature = "mcp"))] Router::new() }) + .nest("/mcp/oauth/server", { + #[cfg(feature = "mcp")] + { + mcp::oauth_server::global_service().layer(cors.clone()) + } + + #[cfg(not(feature = "mcp"))] + Router::new() + }) .nest("/r", { #[cfg(feature = "http_trigger")] { @@ -724,6 +758,36 @@ pub async fn run_server( .route("/openapi.yaml", get(openapi)) .route("/openapi.json", get(openapi_json)), ) + // Clients must use workspace-scoped OAuth metadata at: + // /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server + // This is discovered via /.well-known/oauth-protected-resource?workspace_id=... + .route( + "/.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server", + { + #[cfg(feature = "mcp")] + { + get(mcp::oauth_server::workspaced_oauth_metadata) + } + #[cfg(not(feature = "mcp"))] + { + get(|| async { axum::http::StatusCode::NOT_FOUND }) + } + }, + ) + // RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp + .route( + "/.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp", + { + #[cfg(feature = "mcp")] + { + get(mcp::oauth_server::protected_resource_metadata_by_path) + } + #[cfg(not(feature = "mcp"))] + { + get(|| async { axum::http::StatusCode::NOT_FOUND }) + } + }, + ) // JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix) .route("/.well-known/jwks.json", get(settings::get_jwks)) .fallback(static_assets::static_handler) diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 6b5917398e..be5bd92f86 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -411,6 +411,53 @@ pub async fn extract_and_store_workspace_id( next.run(request).await } +/// Middleware that adds WWW-Authenticate header to 401 responses +/// This helps MCP clients discover the OAuth authorization server (RFC 9728) +pub async fn add_www_authenticate_header( + request: Request, + next: Next, +) -> Response { + use axum::http::StatusCode; + use windmill_common::BASE_URL; + + // Extract workspace_id before consuming the request + let Some(workspace_id) = request + .extensions() + .get::() + .map(|w| w.0.clone()) + else { + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(axum::body::Body::from("Missing workspace_id in request")) + .unwrap(); + }; + + let response = next.run(request).await; + + // Only add header to 401 Unauthorized responses + if response.status() == StatusCode::UNAUTHORIZED { + let base_url = BASE_URL.read().await; + + // RFC 9728: The resource parameter contains the protected resource URL. + // Clients derive the metadata URL by inserting /.well-known/oauth-protected-resource + // after the host, e.g., http://host/.well-known/oauth-protected-resource/api/mcp/w/test/mcp + let resource_url = format!("{}/api/mcp/w/{}/mcp", base_url, workspace_id); + let www_authenticate = format!("Bearer resource=\"{}\"", resource_url); + + // Reconstruct response with the new header + let (mut parts, body) = response.into_parts(); + parts.headers.insert( + axum::http::header::WWW_AUTHENTICATE, + www_authenticate + .parse() + .unwrap_or_else(|_| "Bearer".parse().unwrap()), + ); + Response::from_parts(parts, body) + } else { + response + } +} + /// Setup the MCP server with HTTP transport pub async fn setup_mcp_server( db: DB, diff --git a/backend/windmill-api/src/mcp/mod.rs b/backend/windmill-api/src/mcp/mod.rs index b7853fd7de..314210bf83 100644 --- a/backend/windmill-api/src/mcp/mod.rs +++ b/backend/windmill-api/src/mcp/mod.rs @@ -8,4 +8,8 @@ mod core; mod utils; // Re-export only what's needed externally -pub use core::{extract_and_store_workspace_id, list_tools_service, setup_mcp_server}; +pub mod oauth_server; +pub use core::{ + add_www_authenticate_header, extract_and_store_workspace_id, list_tools_service, + setup_mcp_server, +}; diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs new file mode 100644 index 0000000000..7f959779b5 --- /dev/null +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -0,0 +1,840 @@ +//! OAuth 2.0 Authorization Server for MCP (RFC 6749, 7591, 7636, 8414, 9728) + +use axum::{ + extract::{Extension, Path, Query}, + response::{IntoResponse, Redirect}, + routing::{get, post}, + Form, Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use windmill_common::{ + error::{Error, Result}, + utils::rd_string, + BASE_URL, DB, +}; + +use crate::db::ApiAuthed; + +/// Token expiration for MCP OAuth tokens (1 week in seconds) +const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60; + +/// Refresh token expiration for MCP OAuth (30 days in seconds) +const MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS: u64 = 30 * 24 * 60 * 60; + +/// RFC 8414 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_types_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_types_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_challenge_methods_supported: Option>, +} + +/// RFC 9728 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes_supported: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bearer_methods_supported: Option>, +} + +#[derive(Debug, Serialize)] +pub struct OAuthJsonError { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, +} + +impl OAuthJsonError { + fn new(error: &str, description: Option<&str>) -> Self { + Self { error: error.to_string(), error_description: description.map(|s| s.to_string()) } + } +} + +impl IntoResponse for OAuthJsonError { + fn into_response(self) -> axum::response::Response { + (axum::http::StatusCode::BAD_REQUEST, Json(self)).into_response() + } +} + +#[derive(Debug, Serialize)] +pub struct OAuthTokenError { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, +} + +impl OAuthTokenError { + fn invalid_request(description: &str) -> Self { + Self { + error: "invalid_request".to_string(), + error_description: Some(description.to_string()), + } + } + + fn invalid_grant(description: &str) -> Self { + Self { + error: "invalid_grant".to_string(), + error_description: Some(description.to_string()), + } + } + + fn unsupported_grant_type(description: &str) -> Self { + Self { + error: "unsupported_grant_type".to_string(), + error_description: Some(description.to_string()), + } + } + + fn server_error(description: &str) -> Self { + Self { error: "server_error".to_string(), error_description: Some(description.to_string()) } + } +} + +impl IntoResponse for OAuthTokenError { + fn into_response(self) -> axum::response::Response { + let status = match self.error.as_str() { + "server_error" => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + _ => axum::http::StatusCode::BAD_REQUEST, + }; + (status, Json(self)).into_response() + } +} + +#[derive(Debug, Deserialize)] +pub struct ClientRegistrationRequest { + pub client_name: String, + pub redirect_uris: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ClientRegistrationResponse { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + pub client_name: String, + pub redirect_uris: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeQuery { + pub response_type: String, + pub client_id: String, + pub redirect_uri: String, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub state: Option, + #[serde(default)] + pub code_challenge: Option, + #[serde(default)] + pub code_challenge_method: Option, + #[serde(default)] + pub resource: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ApprovalForm { + pub client_id: String, + pub redirect_uri: String, + pub scope: String, + pub state: String, + pub code_challenge: String, + pub code_challenge_method: String, +} + +#[derive(Debug, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + #[serde(default)] + pub code: String, + #[serde(default)] + pub redirect_uri: String, + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub code_verifier: Option, + #[serde(default)] + pub refresh_token: Option, +} + +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +struct OAuthClient { + client_id: String, + client_name: String, + redirect_uris: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +struct AuthorizationCode { + code: String, + client_id: String, + user_email: String, + workspace_id: String, + scopes: Vec, + redirect_uri: String, + code_challenge: Option, + code_challenge_method: Option, +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +struct RefreshTokenRow { + id: i64, + refresh_token: String, + access_token: String, + client_id: String, + user_email: String, + workspace_id: String, + scopes: Vec, + token_family: sqlx::types::Uuid, + created_at: chrono::DateTime, + expires_at: chrono::DateTime, + used_at: Option>, + revoked: bool, +} + +fn supported_scopes() -> Vec { + vec![ + "mcp:all".to_string(), + "mcp:favorites".to_string(), + "mcp:scripts:*".to_string(), + "mcp:flows:*".to_string(), + "mcp:endpoints:*".to_string(), + ] +} + +/// GET /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server +pub async fn workspaced_oauth_metadata( + Path(workspace_id): Path, +) -> Json { + let base_url = BASE_URL.read().await; + let issuer = format!("{}/api/w/{}/mcp/oauth/server", base_url, workspace_id); + + Json(AuthorizationMetadata { + issuer, + authorization_endpoint: format!( + "{}/api/w/{}/mcp/oauth/server/authorize", + base_url, workspace_id + ), + token_endpoint: format!("{}/api/w/{}/mcp/oauth/server/token", base_url, workspace_id), + registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", base_url)), + scopes_supported: Some(supported_scopes()), + response_types_supported: Some(vec!["code".to_string()]), + grant_types_supported: Some(vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ]), + code_challenge_methods_supported: Some(vec!["S256".to_string()]), + }) +} + +/// GET /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp +pub async fn protected_resource_metadata_by_path( + Path(workspace_id): Path, +) -> Json { + let base_url = BASE_URL.read().await; + let resource_url = format!("{}/api/mcp/w/{}/mcp", base_url, workspace_id); + let auth_server_url = format!("{}/api/w/{}/mcp/oauth/server", base_url, workspace_id); + Json(ProtectedResourceMetadata { + resource: resource_url, + authorization_servers: vec![auth_server_url], + scopes_supported: Some(supported_scopes()), + bearer_methods_supported: Some(vec!["header".to_string()]), + }) +} + +/// POST /api/mcp/oauth/server/register - dynamic client registration +pub async fn oauth_register( + Extension(db): Extension, + Json(req): Json, +) -> Result<(axum::http::StatusCode, Json)> { + if req.redirect_uris.is_empty() { + return Err(Error::BadRequest( + "At least one redirect_uri is required".to_string(), + )); + } + + let client_id = format!("mcp-client-{}", rd_string(16)); + + sqlx::query!( + "INSERT INTO mcp_oauth_server_client (client_id, client_name, redirect_uris) + VALUES ($1, $2, $3)", + client_id, + req.client_name, + &req.redirect_uris, + ) + .execute(&db) + .await + .map_err(|e| Error::InternalErr(format!("Failed to register client: {}", e)))?; + + Ok(( + axum::http::StatusCode::CREATED, + Json(ClientRegistrationResponse { + client_id, + client_secret: None, + client_name: req.client_name, + redirect_uris: req.redirect_uris, + }), + )) +} + +#[derive(Debug, Serialize)] +pub struct ApprovalResponse { + pub code: String, + pub state: Option, +} + +/// POST /api/w/:workspace_id/mcp/oauth/server/token - exchange code for token or refresh +pub async fn oauth_token( + Extension(db): Extension, + Form(req): Form, +) -> std::result::Result, OAuthTokenError> { + match req.grant_type.as_str() { + "authorization_code" => handle_authorization_code_grant(&db, &req).await, + "refresh_token" => handle_refresh_token_grant(&db, &req).await, + _ => Err(OAuthTokenError::unsupported_grant_type( + "Supported grant types: authorization_code, refresh_token", + )), + } +} + +/// Handle authorization_code grant type +async fn handle_authorization_code_grant( + db: &DB, + req: &TokenRequest, +) -> std::result::Result, OAuthTokenError> { + let auth_code = match sqlx::query_as!( + AuthorizationCode, + "DELETE FROM mcp_oauth_server_code + WHERE code = $1 AND expires_at > now() + RETURNING code, client_id, user_email, workspace_id, scopes, redirect_uri, + code_challenge, code_challenge_method", + req.code + ) + .fetch_optional(db) + .await + { + Ok(Some(code)) => code, + Ok(None) => { + return Err(OAuthTokenError::invalid_grant( + "Invalid or expired authorization code", + )); + } + Err(e) => { + tracing::error!("Database error consuming auth code: {}", e); + return Err(OAuthTokenError::server_error("Database error")); + } + }; + + if auth_code.client_id != req.client_id { + return Err(OAuthTokenError::invalid_grant("client_id mismatch")); + } + + if auth_code.redirect_uri != req.redirect_uri { + return Err(OAuthTokenError::invalid_grant("redirect_uri mismatch")); + } + + let challenge = auth_code.code_challenge.as_ref().ok_or_else(|| { + OAuthTokenError::invalid_grant("Authorization code missing PKCE challenge") + })?; + + let verifier = req + .code_verifier + .as_ref() + .ok_or_else(|| OAuthTokenError::invalid_request("code_verifier is required"))?; + + let method = auth_code.code_challenge_method.as_deref().unwrap_or("S256"); + if method != "S256" { + return Err(OAuthTokenError::invalid_grant( + "Only S256 PKCE method is supported", + )); + } + + if !validate_pkce_s256(verifier, challenge) { + return Err(OAuthTokenError::invalid_grant("Invalid code_verifier")); + } + + let access_token = rd_string(32); + let refresh_token = rd_string(32); + let token_family = sqlx::types::Uuid::new_v4(); + let scopes = auth_code.scopes; + + // Create access token + if let Err(e) = sqlx::query!( + "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) + VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)", + access_token, + auth_code.user_email, + format!("mcp-oauth-{}", auth_code.client_id), + MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), + &scopes, + auth_code.workspace_id, + ) + .execute(db) + .await + { + tracing::error!("Failed to create access token: {}", e); + return Err(OAuthTokenError::server_error( + "Failed to create access token", + )); + } + + // Create refresh token + let refresh_token_result = sqlx::query!( + "INSERT INTO mcp_oauth_refresh_token + (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + refresh_token, + access_token, + auth_code.client_id, + auth_code.user_email, + auth_code.workspace_id, + &scopes, + token_family, + MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(), + ) + .execute(db) + .await; + + let refresh_token_value = match refresh_token_result { + Ok(_) => Some(refresh_token), + Err(e) => { + tracing::error!("Failed to create refresh token: {}", e); + None // Don't include invalid refresh token + } + }; + + Ok(Json(TokenResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: MCP_OAUTH_TOKEN_EXPIRATION_SECS, + scope: Some(scopes.join(" ")), + refresh_token: refresh_token_value, + })) +} + +/// Handle refresh_token grant type with token rotation and theft detection +async fn handle_refresh_token_grant( + db: &DB, + req: &TokenRequest, +) -> std::result::Result, OAuthTokenError> { + let refresh_token_value = req + .refresh_token + .as_ref() + .ok_or_else(|| OAuthTokenError::invalid_request("refresh_token is required"))?; + + if req.client_id.is_empty() { + return Err(OAuthTokenError::invalid_request("client_id is required")); + } + + // Atomically claim the refresh token by setting used_at in a single UPDATE. + let token_row = match sqlx::query_as!( + RefreshTokenRow, + "UPDATE mcp_oauth_refresh_token + SET used_at = now() + WHERE refresh_token = $1 + AND client_id = $2 + AND used_at IS NULL + AND NOT revoked + AND expires_at > now() + RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id, + scopes, token_family, created_at, expires_at, used_at, revoked", + refresh_token_value, + req.client_id + ) + .fetch_optional(db) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + // Check for token reuse (theft detection) and revoke family if detected + if let Ok(Some(family)) = sqlx::query_scalar!( + "SELECT token_family FROM mcp_oauth_refresh_token + WHERE refresh_token = $1 AND used_at IS NOT NULL", + refresh_token_value + ) + .fetch_optional(db) + .await + { + tracing::warn!("Refresh token reuse detected, revoking family {:?}", family); + let _ = sqlx::query!( + "UPDATE mcp_oauth_refresh_token SET revoked = TRUE WHERE token_family = $1", + family + ) + .execute(db) + .await; + } + return Err(OAuthTokenError::invalid_grant("Invalid refresh token")); + } + Err(e) => { + tracing::error!("Database error claiming refresh token: {}", e); + return Err(OAuthTokenError::server_error("Database error")); + } + }; + + // Delete old access token + if let Err(e) = sqlx::query!("DELETE FROM token WHERE token = $1", token_row.access_token) + .execute(db) + .await + { + tracing::error!("Failed to delete old access token: {}", e); + // Non-fatal, continue with token creation + } + + // Generate new tokens + let new_access_token = rd_string(32); + let new_refresh_token = rd_string(32); + let scopes = token_row.scopes; + + // Create new access token + if let Err(e) = sqlx::query!( + "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) + VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)", + new_access_token, + token_row.user_email, + format!("mcp-oauth-{}", token_row.client_id), + MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), + &scopes, + token_row.workspace_id, + ) + .execute(db) + .await + { + tracing::error!("Failed to create new access token: {}", e); + return Err(OAuthTokenError::server_error( + "Failed to create access token", + )); + } + + // Create new refresh token (same token family for tracking) + if let Err(e) = sqlx::query!( + "INSERT INTO mcp_oauth_refresh_token + (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + new_refresh_token, + new_access_token, + token_row.client_id, + token_row.user_email, + token_row.workspace_id, + &scopes, + token_row.token_family, + MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(), + ) + .execute(db) + .await + { + tracing::error!("Failed to create new refresh token: {}", e); + // Access token was created, return success without refresh token + } + + Ok(Json(TokenResponse { + access_token: new_access_token, + token_type: "Bearer".to_string(), + expires_in: MCP_OAUTH_TOKEN_EXPIRATION_SECS, + scope: Some(scopes.join(" ")), + refresh_token: Some(new_refresh_token), + })) +} + +/// GET /api/w/:workspace_id/mcp/oauth/server/authorize - redirects to consent page +pub async fn workspaced_oauth_authorize( + Extension(db): Extension, + Path(workspace_id): Path, + Query(params): Query, +) -> impl IntoResponse { + let client = match sqlx::query_as!( + OAuthClient, + "SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1", + params.client_id + ) + .fetch_optional(&db) + .await + { + Ok(Some(client)) => client, + Ok(None) => { + return OAuthJsonError::new("invalid_client", Some("Unknown client_id")) + .into_response(); + } + Err(e) => { + tracing::error!("Database error looking up client: {}", e); + return OAuthJsonError::new("server_error", Some("Database error")) + .into_response(); + } + }; + + if !client.redirect_uris.contains(¶ms.redirect_uri) { + return OAuthJsonError::new( + "invalid_request", + Some("redirect_uri does not match registered URIs"), + ) + .into_response(); + } + + if params.response_type != "code" { + return OAuthErrorRedirect::new( + ¶ms.redirect_uri, + "unsupported_response_type", + Some("Only 'code' response type is supported"), + params.state.as_deref(), + ) + .into_response(); + } + + let code_challenge = match ¶ms.code_challenge { + Some(challenge) if !challenge.is_empty() => challenge.as_str(), + _ => { + return OAuthErrorRedirect::new( + ¶ms.redirect_uri, + "invalid_request", + Some("PKCE required: code_challenge parameter is mandatory"), + params.state.as_deref(), + ) + .into_response(); + } + }; + + let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("S256"); + + if code_challenge_method != "S256" { + return OAuthErrorRedirect::new( + ¶ms.redirect_uri, + "invalid_request", + Some("Invalid code_challenge_method: only 'S256' is supported"), + params.state.as_deref(), + ) + .into_response(); + } + + let resource = match ¶ms.resource { + Some(r) => r, + None => { + return OAuthErrorRedirect::new( + ¶ms.redirect_uri, + "invalid_request", + Some("Missing 'resource' parameter. Required for MCP audience binding."), + params.state.as_deref(), + ) + .into_response(); + } + }; + + let base_url = BASE_URL.read().await; + let frontend_url = format!( + "{}/oauth/mcp_authorize?{}", + base_url, + serde_urlencoded::to_string(&[ + ("workspace_id", workspace_id.as_str()), + ("client_id", params.client_id.as_str()), + ("client_name", client.client_name.as_str()), + ("redirect_uri", params.redirect_uri.as_str()), + ("scope", params.scope.as_deref().unwrap_or("mcp:all")), + ("state", params.state.as_deref().unwrap_or("")), + ("code_challenge", code_challenge), + ("code_challenge_method", code_challenge_method), + ("resource", resource), + ]) + .unwrap_or_default() + ); + + Redirect::temporary(&frontend_url).into_response() +} + +/// POST /api/w/:workspace_id/mcp/oauth/server/approve - user approval (frontend) +pub async fn workspaced_oauth_approve( + Extension(db): Extension, + Path(workspace_id): Path, + authed: ApiAuthed, + Json(form): Json, +) -> Result> { + // Verify user is a member of the workspace + let is_member = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", + workspace_id, + authed.email + ) + .fetch_one(&db) + .await + .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? + .unwrap_or(false); + + if !is_member { + return Err(Error::NotAuthorized( + "User is not a member of this workspace".to_string(), + )); + } + + // Verify client exists and redirect_uri is registered + let client = sqlx::query_as!( + OAuthClient, + "SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1", + form.client_id + ) + .fetch_optional(&db) + .await + .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? + .ok_or_else(|| Error::BadRequest("Unknown client_id".to_string()))?; + + if !client.redirect_uris.contains(&form.redirect_uri) { + return Err(Error::BadRequest( + "Invalid redirect_uri for this client".to_string(), + )); + } + + if form.code_challenge.is_empty() { + return Err(Error::BadRequest( + "PKCE required: code_challenge is mandatory".to_string(), + )); + } + + if form.code_challenge_method.is_empty() { + return Err(Error::BadRequest( + "PKCE required: code_challenge_method is mandatory".to_string(), + )); + } + + if form.code_challenge_method != "S256" { + return Err(Error::BadRequest( + "Invalid code_challenge_method: only 'S256' is supported".to_string(), + )); + } + + let code = format!("mcp-code-{}", rd_string(32)); + + let scopes: Vec = form + .scope + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + + sqlx::query!( + "INSERT INTO mcp_oauth_server_code + (code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + code, + form.client_id, + authed.email, + workspace_id, + &scopes, + form.redirect_uri, + &form.code_challenge, + &form.code_challenge_method, + ) + .execute(&db) + .await + .map_err(|e| Error::InternalErr(format!("Failed to store authorization code: {}", e)))?; + + Ok(Json(ApprovalResponse { + code, + state: if form.state.is_empty() { + None + } else { + Some(form.state) + }, + })) +} + +/// PKCE validation (S256 only) +fn validate_pkce_s256(verifier: &str, challenge: &str) -> bool { + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let computed = base64_url_encode(&hasher.finalize()); + constant_time_eq(computed.as_bytes(), challenge.as_bytes()) +} + +/// Base64 URL encoding (no padding) +fn base64_url_encode(data: &[u8]) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD.encode(data) +} + +/// Constant-time comparison +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Helper for OAuth error redirects +struct OAuthErrorRedirect { + redirect_uri: String, + error: String, + error_description: Option, + state: Option, +} + +impl OAuthErrorRedirect { + fn new( + redirect_uri: &str, + error: &str, + error_description: Option<&str>, + state: Option<&str>, + ) -> Self { + Self { + redirect_uri: redirect_uri.to_string(), + error: error.to_string(), + error_description: error_description.map(|s| s.to_string()), + state: state.map(|s| s.to_string()), + } + } +} + +impl IntoResponse for OAuthErrorRedirect { + fn into_response(self) -> axum::response::Response { + let mut url = format!("{}?error={}", self.redirect_uri, self.error); + if let Some(desc) = &self.error_description { + url.push_str(&format!("&error_description={}", urlencoding::encode(desc))); + } + if let Some(state) = &self.state { + url.push_str(&format!("&state={}", state)); + } + Redirect::temporary(&url).into_response() + } +} + +/// Mounted at /api/mcp/oauth/server +pub fn global_service() -> Router { + Router::new().route("/register", post(oauth_register)) +} + +/// Workspace-scoped OAuth endpoints that don't require authentication +/// Mounted at /api/w/:workspace_id/mcp/oauth/server (outside authenticated section) +pub fn workspaced_unauthed_service() -> Router { + Router::new() + .route("/authorize", get(workspaced_oauth_authorize)) + .route("/token", post(oauth_token)) +} + +/// Workspace-scoped OAuth endpoints that require authentication +/// Mounted at /api/w/:workspace_id/mcp/oauth/server (inside authenticated section) +pub fn workspaced_authed_service() -> Router { + Router::new().route("/approve", post(workspaced_oauth_approve)) +} diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 64b73ad3f4..abaa7c4874 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -175,7 +175,7 @@ } const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore)) - const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/sse?token=`) + const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=`) const warning = $derived( newMcpScope === 'all' diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte new file mode 100644 index 0000000000..01fec29ff0 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte @@ -0,0 +1,150 @@ + + +{#if !workspaceId} +

Error: missing workspace_id

+{:else} + + {#if success} +
+
+ +
+

+ Authorization granted to {clientName}. +

+

+ You should be redirected automatically. If not, click the link below: +

+ + {successRedirectUrl} + +
+ {:else} +

+ {clientName} + is requesting access to your + {workspaceId} + workspace. +

+ +
+

This will allow the client to:

+
    +
  • + + Execute all scripts in the workspace +
  • +
  • + + Execute all flows in the workspace +
  • +
  • + + Access API endpoints related to the workspace +
  • +
+
+ +
+ +

+ For more fine-grained control, you can create a specific token with limited scope from + your account settings. + See documentation. +

+
+ +
+ + +
+ {/if} +
+{/if} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 614299ad1a..868c416fb0 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -35,6 +35,11 @@ const config = { port: 3000, cors: { origin: '*' }, proxy: { + '^/\\.well-known/.*': { + target: process.env.REMOTE ?? 'https://app.windmill.dev', + changeOrigin: true, + cookieDomainRewrite: 'localhost' + }, '^/api/w/[^/]+/s3_proxy/.*': { target: process.env.REMOTE ?? 'https://app.windmill.dev/', changeOrigin: false, // Important for signature to be correct