mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
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
This commit is contained in:
+21
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+64
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+89
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE token = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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<Vec<i64>, _> = 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;
|
||||
|
||||
@@ -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::<ApiAuthed>())
|
||||
.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::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
// 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::<OptAuthed>())
|
||||
// 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)
|
||||
|
||||
@@ -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<axum::body::Body>,
|
||||
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::<WorkspaceId>()
|
||||
.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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_types_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grant_types_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_challenge_methods_supported: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// RFC 9728
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProtectedResourceMetadata {
|
||||
pub resource: String,
|
||||
pub authorization_servers: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bearer_methods_supported: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OAuthJsonError {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientRegistrationResponse {
|
||||
pub client_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_secret: Option<String>,
|
||||
pub client_name: String,
|
||||
pub redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeQuery {
|
||||
pub response_type: String,
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub code_challenge: Option<String>,
|
||||
#[serde(default)]
|
||||
pub code_challenge_method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub resource: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, FromRow)]
|
||||
struct OAuthClient {
|
||||
client_id: String,
|
||||
client_name: String,
|
||||
redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, FromRow)]
|
||||
struct AuthorizationCode {
|
||||
code: String,
|
||||
client_id: String,
|
||||
user_email: String,
|
||||
workspace_id: String,
|
||||
scopes: Vec<String>,
|
||||
redirect_uri: String,
|
||||
code_challenge: Option<String>,
|
||||
code_challenge_method: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
token_family: sqlx::types::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
expires_at: chrono::DateTime<chrono::Utc>,
|
||||
used_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
revoked: bool,
|
||||
}
|
||||
|
||||
fn supported_scopes() -> Vec<String> {
|
||||
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<String>,
|
||||
) -> Json<AuthorizationMetadata> {
|
||||
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<String>,
|
||||
) -> Json<ProtectedResourceMetadata> {
|
||||
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<DB>,
|
||||
Json(req): Json<ClientRegistrationRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<ClientRegistrationResponse>)> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// POST /api/w/:workspace_id/mcp/oauth/server/token - exchange code for token or refresh
|
||||
pub async fn oauth_token(
|
||||
Extension(db): Extension<DB>,
|
||||
Form(req): Form<TokenRequest>,
|
||||
) -> std::result::Result<Json<TokenResponse>, 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<Json<TokenResponse>, 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<Json<TokenResponse>, 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<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(params): Query<AuthorizeQuery>,
|
||||
) -> 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<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
Json(form): Json<ApprovalForm>,
|
||||
) -> Result<Json<ApprovalResponse>> {
|
||||
// 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<String> = 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<String>,
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Check, Info } from 'lucide-svelte'
|
||||
|
||||
// Get OAuth params from URL
|
||||
let workspaceId = $page.url.searchParams.get('workspace_id') || ''
|
||||
let clientId = $page.url.searchParams.get('client_id') || ''
|
||||
let clientName = $page.url.searchParams.get('client_name') || 'Unknown Client'
|
||||
let redirectUri = $page.url.searchParams.get('redirect_uri') || ''
|
||||
let scope = $page.url.searchParams.get('scope') || 'mcp:all'
|
||||
let oauthState = $page.url.searchParams.get('state') || ''
|
||||
let codeChallenge = $page.url.searchParams.get('code_challenge') || ''
|
||||
let codeChallengeMethod = $page.url.searchParams.get('code_challenge_method') || ''
|
||||
|
||||
let loading = $state(false)
|
||||
let success = $state(false)
|
||||
let successRedirectUrl = $state('')
|
||||
|
||||
function onDeny() {
|
||||
// Redirect to client with error
|
||||
const params = new URLSearchParams({
|
||||
error: 'access_denied',
|
||||
error_description: 'User denied the authorization request'
|
||||
})
|
||||
if (oauthState) {
|
||||
params.set('state', oauthState)
|
||||
}
|
||||
window.location.href = `${redirectUri}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
if (!workspaceId) {
|
||||
sendUserToast('Error: missing workspace_id', true)
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
const approveUrl = `/api/w/${workspaceId}/mcp/oauth/server/approve`
|
||||
const response = await fetch(approveUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scope,
|
||||
state: oauthState,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Include state in redirect if present
|
||||
const params = new URLSearchParams({ code: data.code })
|
||||
if (data.state) {
|
||||
params.set('state', data.state)
|
||||
}
|
||||
const url = `${redirectUri}?${params.toString()}`
|
||||
success = true
|
||||
successRedirectUrl = url
|
||||
loading = false
|
||||
window.location.href = url
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
const errorMsg = errorData?.message || `Server returned ${response.status}`
|
||||
sendUserToast(`Error: ${errorMsg}`, true)
|
||||
loading = false
|
||||
}
|
||||
} catch (e) {
|
||||
sendUserToast('Error approving authorization request', true)
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !workspaceId}
|
||||
<p class="text-center text-sm text-primary mb-6"> Error: missing workspace_id </p>
|
||||
{:else}
|
||||
<CenteredModal title={success ? 'Authorization Approved' : 'Authorization Request'}>
|
||||
{#if success}
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-green-500">
|
||||
<Check class="w-16 h-16 mx-auto" />
|
||||
</div>
|
||||
<p class="text-sm text-primary mb-4">
|
||||
Authorization granted to <span class="font-semibold text-accent">{clientName}</span>.
|
||||
</p>
|
||||
<p class="text-xs text-secondary mb-4">
|
||||
You should be redirected automatically. If not, click the link below:
|
||||
</p>
|
||||
<a href={successRedirectUrl} class="text-xs text-accent hover:underline break-all">
|
||||
{successRedirectUrl}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-sm text-primary mb-6">
|
||||
<span class="font-semibold text-accent">{clientName}</span>
|
||||
is requesting access to your
|
||||
<span class="font-semibold text-accent">{workspaceId}</span>
|
||||
workspace.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-emphasis mb-3">This will allow the client to:</p>
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
<li class="flex items-center gap-x-2 text-xs text-primary">
|
||||
<Check class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
Execute all scripts in the workspace
|
||||
</li>
|
||||
<li class="flex items-center gap-x-2 text-xs text-primary">
|
||||
<Check class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
Execute all flows in the workspace
|
||||
</li>
|
||||
<li class="flex items-center gap-x-2 text-xs text-primary">
|
||||
<Check class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
Access API endpoints related to the workspace
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-x-2 p-3 mb-6 rounded-md bg-surface-secondary border border-light"
|
||||
>
|
||||
<Info class="w-4 h-4 text-secondary flex-shrink-0 mt-0.5" />
|
||||
<p class="text-2xs text-secondary">
|
||||
For more fine-grained control, you can create a specific token with limited scope from
|
||||
your account settings.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">See documentation</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-around gap-x-4">
|
||||
<Button variant="border" size="lg" disabled={loading} onClick={onDeny}>Deny</Button>
|
||||
<Button variant="accent" size="lg" disabled={loading} {loading} onClick={onApprove}
|
||||
>Approve</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
{/if}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user