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:
centdix
2026-01-20 20:19:21 +01:00
committed by GitHub
parent b0a0efbaad
commit 2c4d86adc1
23 changed files with 1556 additions and 9 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a"
}
@@ -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"
}
@@ -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);
+33
View File
@@ -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;
+70 -6
View File
@@ -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)
+47
View File
@@ -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,
+5 -1
View File
@@ -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(&params.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(
&params.redirect_uri,
"unsupported_response_type",
Some("Only 'code' response type is supported"),
params.state.as_deref(),
)
.into_response();
}
let code_challenge = match &params.code_challenge {
Some(challenge) if !challenge.is_empty() => challenge.as_str(),
_ => {
return OAuthErrorRedirect::new(
&params.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(
&params.redirect_uri,
"invalid_request",
Some("Invalid code_challenge_method: only 'S256' is supported"),
params.state.as_deref(),
)
.into_response();
}
let resource = match &params.resource {
Some(r) => r,
None => {
return OAuthErrorRedirect::new(
&params.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}
+5
View File
@@ -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