From d6a479f5ea42a1afa1198d6178b52d7bc1fd7c2d Mon Sep 17 00:00:00 2001 From: bowlofarugula Date: Tue, 5 Aug 2025 15:10:01 -0700 Subject: [PATCH] feat: Clean up authorizer config and API --- Cargo.lock | 6 +- README.md | 4 +- cli/src/main.rs | 34 +- components/mcp-authorizer/Cargo.toml | 2 +- components/mcp-authorizer/spin.toml | 6 + components/mcp-authorizer/src/auth.rs | 3 + components/mcp-authorizer/src/config.rs | 26 +- components/mcp-authorizer/src/forwarding.rs | 124 +- components/mcp-authorizer/src/jwks.rs | 8 +- components/mcp-authorizer/src/lib.rs | 23 +- components/mcp-authorizer/src/static_token.rs | 1 + components/mcp-authorizer/src/token.rs | 29 +- components/mcp-authorizer/tests/src/lib.rs | 1 + .../tests/src/tenant_validation_tests.rs | 256 ++++ .../tests/src/test_token_utils.rs | 11 +- crates/commands/src/commands/build_tests.rs | 7 +- crates/commands/src/commands/deploy.rs | 293 +++-- crates/commands/src/commands/deploy_tests.rs | 573 +++++++- crates/commands/src/commands/init.rs | 5 +- crates/commands/src/commands/up_tests.rs | 7 +- crates/commands/src/config/ftl_config.rs | 1165 ++--------------- crates/commands/src/config/mod.rs | 2 +- crates/commands/src/config/transpiler.rs | 218 +-- .../commands/src/config/transpiler_tests.rs | 181 ++- crates/commands/src/test_helpers.rs | 6 +- crates/runtime/openapi.json | 36 +- examples/demo/ftl.toml | 65 +- templates/ftl-mcp-server/content/ftl.toml | 29 +- 28 files changed, 1517 insertions(+), 1604 deletions(-) create mode 100644 components/mcp-authorizer/tests/src/tenant_validation_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 13942c3e..128c1e0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,7 +1122,7 @@ dependencies = [ [[package]] name = "ftl-mcp-authorizer" -version = "0.0.10" +version = "0.0.12" dependencies = [ "anyhow", "chrono", @@ -2003,9 +2003,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libbz2-rs-sys" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775bf80d5878ab7c2b1080b5351a48b2f737d9f6f8b383574eebcc22be0dfccb" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" diff --git a/README.md b/README.md index 5dd95b07..413c80fb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # `ftl` -Fast tools for AI agents +Faster tools for AI agents [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![WebAssembly](https://img.shields.io/badge/WebAssembly-compatible-purple.svg)](https://webassembly.org/) @@ -15,7 +15,7 @@ Fast tools for AI agents -FTL is a framework for polyglot [Model Context Protocol](https://modelcontextprotocol.io) servers. It composes [WebAssembly components](https://component-model.bytecodealliance.org/design/why-component-model.html) via [Spin](https://github.com/spinframework/spin) to present a *just works* story for adding capabilities to AI agents with performant, sandboxed, edge-ready tools. +FTL is a framework to write and run polyglot [Model Context Protocol](https://modelcontextprotocol.io) servers on [WebAssembly components](https://component-model.bytecodealliance.org/design/why-component-model.html) via [Spin](https://github.com/spinframework/spin). Tools authored in multiple [source languages](./sdk/README.md) can run simultaneously in a single MCP server process on any host compatible with Spin/[Wasmtime](https://github.com/bytecodealliance/wasmtime), including your development machine. diff --git a/cli/src/main.rs b/cli/src/main.rs index 6885e995..d161e974 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -170,24 +170,28 @@ enum EngCommand { #[arg(long, value_name = "KEY=VALUE")] variable: Vec, - /// Set authentication mode (public, ftl-account, user-only, custom) - #[arg(long, value_name = "MODE")] - auth_mode: Option, - - /// Allowed users for user-only mode (comma-separated) - #[arg(long, value_name = "USER_IDS", requires = "auth_mode")] - auth_users: Option, - - /// Custom auth provider (authkit, auth0, oidc) - #[arg(long, value_name = "PROVIDER", requires = "auth_mode")] + /// Set access control mode (public, private, custom) + /// Overrides `FTL_ACCESS_CONTROL` env var and ftl.toml `auth.access_control` + #[arg( + long = "access-control", + value_name = "public|private|custom", + help_heading = "Authentication" + )] + access_control: Option, + + /// Custom auth provider (authkit, auth0, oidc, static) + /// Overrides `FTL_AUTH_PROVIDER` env var and ftl.toml auth provider + #[arg(long, value_name = "PROVIDER", help_heading = "Authentication")] auth_provider: Option, /// Custom auth issuer URL - #[arg(long, value_name = "URL", requires = "auth_provider")] + /// Overrides `FTL_AUTH_ISSUER` env var and ftl.toml auth issuer + #[arg(long, value_name = "URL", help_heading = "Authentication")] auth_issuer: Option, /// Custom auth audience - #[arg(long, value_name = "AUDIENCE", requires = "auth_provider")] + /// Overrides `FTL_AUTH_AUDIENCE` env var and ftl.toml auth audience + #[arg(long, value_name = "AUDIENCE", help_heading = "Authentication")] auth_audience: Option, /// Run without making any changes (preview what would be deployed) @@ -570,8 +574,7 @@ async fn handle_eng_command(args: EngArgs) -> Result<()> { } EngCommand::Deploy { variable, - auth_mode, - auth_users, + access_control, auth_provider, auth_issuer, auth_audience, @@ -579,8 +582,7 @@ async fn handle_eng_command(args: EngArgs) -> Result<()> { } => { let deploy_args = ftl_commands::deploy::DeployArgs { variables: variable, - auth_mode, - auth_users, + access_control, auth_provider, auth_issuer, auth_audience, diff --git a/components/mcp-authorizer/Cargo.toml b/components/mcp-authorizer/Cargo.toml index 8086a9e1..b8620df5 100644 --- a/components/mcp-authorizer/Cargo.toml +++ b/components/mcp-authorizer/Cargo.toml @@ -2,7 +2,7 @@ name = "ftl-mcp-authorizer" authors.workspace = true description = "MCP authorization component for FTL servers using AuthKit" -version = "0.0.10" +version = "0.0.12" license.workspace = true rust-version.workspace = true edition.workspace = true diff --git a/components/mcp-authorizer/spin.toml b/components/mcp-authorizer/spin.toml index 4bc7fa6e..3234526f 100644 --- a/components/mcp-authorizer/spin.toml +++ b/components/mcp-authorizer/spin.toml @@ -28,6 +28,9 @@ mcp_oauth_userinfo_endpoint = { default = "" } # Static provider settings mcp_static_tokens = { default = "" } +# Tenant restriction (for private mode) +mcp_tenant_id = { default = "" } + [[trigger.http]] route = "/..." component = "mcp-authorizer" @@ -62,6 +65,9 @@ mcp_oauth_userinfo_endpoint = "{{ mcp_oauth_userinfo_endpoint }}" # Static provider settings mcp_static_tokens = "{{ mcp_static_tokens }}" +# Tenant restriction (for private mode) +mcp_tenant_id = "{{ mcp_tenant_id }}" + # Test configuration [component.mcp-authorizer.tool.spin-test] source = "tests/target/wasm32-wasip1/release/tests.wasm" diff --git a/components/mcp-authorizer/src/auth.rs b/components/mcp-authorizer/src/auth.rs index 2cebdfc5..e76b6957 100644 --- a/components/mcp-authorizer/src/auth.rs +++ b/components/mcp-authorizer/src/auth.rs @@ -20,6 +20,9 @@ pub struct Context { /// Raw bearer token (for forwarding if needed) pub raw_token: String, + + /// Organization ID (optional) + pub org_id: Option, } /// Extract bearer token from request diff --git a/components/mcp-authorizer/src/config.rs b/components/mcp-authorizer/src/config.rs index 8c99f75b..6d8be684 100644 --- a/components/mcp-authorizer/src/config.rs +++ b/components/mcp-authorizer/src/config.rs @@ -15,6 +15,9 @@ pub struct Config { /// JWT provider configuration (always required) pub provider: Provider, + + /// Required tenant ID for private mode (optional) + pub tenant_id: Option, } /// Provider type enumeration @@ -79,6 +82,9 @@ pub struct StaticTokenInfo { /// Optional expiration timestamp pub expires_at: Option, + + /// Organization ID (optional) + pub org_id: Option, } /// OAuth 2.0 endpoint configuration @@ -102,10 +108,16 @@ impl Config { // Provider configuration is always required let provider = Provider::load()?; + // Load tenant ID for private mode + let tenant_id = variables::get("mcp_tenant_id") + .ok() + .filter(|s| !s.is_empty()); + Ok(Self { gateway_url, trace_header, provider, + tenant_id, }) } } @@ -223,7 +235,7 @@ impl Provider { use std::collections::HashMap; // Load static tokens from configuration - // Format: mcp_static_tokens = "token1:client1:user1:read,write;token2:client2:user2:admin" + // Format: mcp_static_tokens = "token1:client1:user1:read,write[:expires_at[:org_id]];token2:client2:user2:admin" let tokens_config = variables::get("mcp_static_tokens") .map_err(|_| anyhow::anyhow!("Missing mcp_static_tokens for static provider"))?; @@ -262,6 +274,17 @@ impl Provider { // Optional expiration timestamp as 5th part let expires_at = parts.get(4).and_then(|s| s.parse::().ok()); + // Optional org_id as 6th part (or 5th if no expiration) + let org_id = if expires_at.is_some() { + parts.get(5).map(|s| (*s).to_string()) + } else { + // If 5th part is not a number, treat it as org_id + parts + .get(4) + .filter(|s| s.parse::().is_err()) + .map(|s| (*s).to_string()) + }; + tokens.insert( token, StaticTokenInfo { @@ -269,6 +292,7 @@ impl Provider { sub, scopes, expires_at, + org_id, }, ); } diff --git a/components/mcp-authorizer/src/forwarding.rs b/components/mcp-authorizer/src/forwarding.rs index 0c165685..d4a6e27d 100644 --- a/components/mcp-authorizer/src/forwarding.rs +++ b/components/mcp-authorizer/src/forwarding.rs @@ -1,6 +1,6 @@ //! Request forwarding to the MCP gateway -use spin_sdk::http::{Request, Response}; +use spin_sdk::http::{Headers, Request, Response}; use crate::auth::Context as AuthContext; use crate::config::Config; @@ -15,47 +15,8 @@ pub async fn forward_to_gateway( // Parse gateway URL to set the scheme and authority let gateway_url = url::Url::parse(&config.gateway_url)?; - // Create headers first - let headers = spin_sdk::http::Headers::new(); - - // Copy request headers - for (name, value) in req.headers() { - headers.append(&name.to_string(), &value.as_bytes().to_vec())?; - } - - // Add authentication context headers - headers.append( - &"x-auth-client-id".to_string(), - &auth_context.client_id.as_bytes().to_vec(), - )?; - headers.append( - &"x-auth-user-id".to_string(), - &auth_context.user_id.as_bytes().to_vec(), - )?; - headers.append( - &"x-auth-issuer".to_string(), - &auth_context.issuer.as_bytes().to_vec(), - )?; - - if !auth_context.scopes.is_empty() { - headers.append( - &"x-auth-scopes".to_string(), - &auth_context.scopes.join(" ").as_bytes().to_vec(), - )?; - } - - // Forward the original authorization header - headers.append( - &"authorization".to_string(), - &format!("Bearer {}", auth_context.raw_token) - .as_bytes() - .to_vec(), - )?; - - // Add trace ID if present - if let Some(trace_id) = &trace_id { - headers.append(&config.trace_header, &trace_id.as_bytes().to_vec())?; - } + // Build headers with authentication context + let headers = build_forwarding_headers(&req, &auth_context, trace_id.as_ref(), config)?; // Build outgoing request with the headers let outgoing = spin_sdk::http::OutgoingRequest::new(headers); @@ -126,7 +87,80 @@ pub async fn forward_to_gateway( // Extract body let body = incoming_response.into_body(); - // Build the final response + // Build response with proper headers + Ok(build_gateway_response( + status, + headers_vec, + body, + trace_id, + &config.trace_header, + )) +} + +/// Build headers for forwarding request +fn build_forwarding_headers( + req: &Request, + auth_context: &AuthContext, + trace_id: Option<&String>, + config: &Config, +) -> anyhow::Result { + let headers = Headers::new(); + + // Copy request headers + for (name, value) in req.headers() { + headers.append(&name.to_string(), &value.as_bytes().to_vec())?; + } + + // Add authentication context headers + headers.append( + &"x-auth-client-id".to_string(), + &auth_context.client_id.as_bytes().to_vec(), + )?; + headers.append( + &"x-auth-user-id".to_string(), + &auth_context.user_id.as_bytes().to_vec(), + )?; + headers.append( + &"x-auth-issuer".to_string(), + &auth_context.issuer.as_bytes().to_vec(), + )?; + + if !auth_context.scopes.is_empty() { + headers.append( + &"x-auth-scopes".to_string(), + &auth_context.scopes.join(" ").as_bytes().to_vec(), + )?; + } + + // Add organization ID if present + if let Some(org_id) = &auth_context.org_id { + headers.append(&"x-auth-org-id".to_string(), &org_id.as_bytes().to_vec())?; + } + + // Forward the original authorization header + headers.append( + &"authorization".to_string(), + &format!("Bearer {}", auth_context.raw_token) + .as_bytes() + .to_vec(), + )?; + + // Add trace ID if present + if let Some(trace_id) = trace_id { + headers.append(&config.trace_header, &trace_id.as_bytes().to_vec())?; + } + + Ok(headers) +} + +/// Build gateway response with CORS headers +fn build_gateway_response( + status: u16, + headers_vec: Vec<(String, String)>, + body: Vec, + trace_id: Option, + trace_header: &str, +) -> Response { let mut binding = Response::builder(); let mut response_builder = binding.status(status); @@ -149,9 +183,9 @@ pub async fn forward_to_gateway( // Add trace ID if present if let Some(trace_id) = trace_id { - response_builder = response_builder.header(&config.trace_header, trace_id); + response_builder = response_builder.header(trace_header, trace_id); } // Build the response with body - Ok(response_builder.body(body).build()) + response_builder.body(body).build() } diff --git a/components/mcp-authorizer/src/jwks.rs b/components/mcp-authorizer/src/jwks.rs index 4a3ddb00..61d11fcc 100644 --- a/components/mcp-authorizer/src/jwks.rs +++ b/components/mcp-authorizer/src/jwks.rs @@ -70,17 +70,15 @@ pub async fn fetch_jwks(jwks_uri: &str, store: &Store) -> Result { } // Fetch JWKS from URI - eprintln!("Fetching JWKS from: {jwks_uri}"); let request = spin_sdk::http::Request::builder() .method(spin_sdk::http::Method::Get) .uri(jwks_uri) .header("Accept", "application/json") .build(); - let response: Response = spin_sdk::http::send(request).await.map_err(|e| { - eprintln!("Failed to fetch JWKS from {jwks_uri}: {e}"); - AuthError::Internal(format!("Failed to fetch JWKS: {e}")) - })?; + let response: Response = spin_sdk::http::send(request) + .await + .map_err(|e| AuthError::Internal(format!("Failed to fetch JWKS: {e}")))?; if *response.status() != 200 { return Err(AuthError::Internal(format!( diff --git a/components/mcp-authorizer/src/lib.rs b/components/mcp-authorizer/src/lib.rs index e818f627..4ea2a5c9 100644 --- a/components/mcp-authorizer/src/lib.rs +++ b/components/mcp-authorizer/src/lib.rs @@ -40,18 +40,10 @@ async fn handle_request(req: Request) -> anyhow::Result { // Authenticate the request - auth is always required match authenticate(&req, &config).await { Ok(auth_context) => { - // Log successful authentication - eprintln!( - "AUTH_SUCCESS path={} client_id={}", - req.path(), - auth_context.client_id - ); // Forward authenticated request forward_request(req, &config, auth_context, trace_id).await } Err(auth_error) => { - // Log the error with more context - eprintln!("AUTH_ERROR path={} error={:?}", req.path(), auth_error); // Return authentication error Ok(create_error_response(&auth_error, &req, &config, trace_id)) } @@ -79,13 +71,26 @@ async fn authenticate(req: &Request, config: &Config) -> Result { } }; + // Check tenant restriction if configured + if let Some(required_tenant) = &config.tenant_id { + // Extract tenant ID from token (org_id for organizations, sub for individuals) + let token_tenant = token_info.org_id.as_ref().unwrap_or(&token_info.sub); + + if token_tenant != required_tenant { + return Err(AuthError::Unauthorized( + "Access denied: invalid tenant".to_string(), + )); + } + } + // Build auth context Ok(auth::Context { client_id: token_info.client_id, - user_id: token_info.sub, + user_id: token_info.sub.clone(), scopes: token_info.scopes, issuer: token_info.iss, raw_token: token.to_string(), + org_id: token_info.org_id, }) } diff --git a/components/mcp-authorizer/src/static_token.rs b/components/mcp-authorizer/src/static_token.rs index 6519fa8c..310870c2 100644 --- a/components/mcp-authorizer/src/static_token.rs +++ b/components/mcp-authorizer/src/static_token.rs @@ -45,5 +45,6 @@ pub fn verify(token: &str, provider: &StaticProvider) -> Result { sub: token_info.sub.clone(), iss: "static".to_string(), // Static provider has no issuer scopes: token_info.scopes.clone(), + org_id: token_info.org_id.clone(), }) } diff --git a/components/mcp-authorizer/src/token.rs b/components/mcp-authorizer/src/token.rs index c9839d7a..b2c42a85 100644 --- a/components/mcp-authorizer/src/token.rs +++ b/components/mcp-authorizer/src/token.rs @@ -22,6 +22,9 @@ pub struct TokenInfo { /// Scopes pub scopes: Vec, + + /// Organization ID (optional) + pub org_id: Option, } /// JWT Claims structure @@ -55,6 +58,10 @@ struct Claims { #[serde(skip_serializing_if = "Option::is_none")] client_id: Option, + /// Organization ID + #[serde(skip_serializing_if = "Option::is_none")] + org_id: Option, + /// Additional claims #[serde(flatten)] additional: serde_json::Map, @@ -138,26 +145,13 @@ pub async fn verify(token: &str, provider: &JwtProvider, store: &Store) -> Resul Err(e) => { // Provide more specific error messages for common issues return match e.kind() { - jsonwebtoken::errors::ErrorKind::ExpiredSignature => { - eprintln!("TOKEN_ERROR type=expired"); - Err(AuthError::ExpiredToken) - } - jsonwebtoken::errors::ErrorKind::InvalidIssuer => { - eprintln!("TOKEN_ERROR type=invalid_issuer"); - Err(AuthError::InvalidIssuer) - } - jsonwebtoken::errors::ErrorKind::InvalidAudience => { - eprintln!("TOKEN_ERROR type=invalid_audience"); - Err(AuthError::InvalidAudience) - } + jsonwebtoken::errors::ErrorKind::ExpiredSignature => Err(AuthError::ExpiredToken), + jsonwebtoken::errors::ErrorKind::InvalidIssuer => Err(AuthError::InvalidIssuer), + jsonwebtoken::errors::ErrorKind::InvalidAudience => Err(AuthError::InvalidAudience), jsonwebtoken::errors::ErrorKind::InvalidSignature => { - eprintln!("TOKEN_ERROR type=invalid_signature"); Err(AuthError::InvalidSignature) } - _ => { - eprintln!("TOKEN_ERROR type=other detail={e:?}"); - Err(AuthError::InvalidToken(e.to_string())) - } + _ => Err(AuthError::InvalidToken(e.to_string())), }; } }; @@ -190,6 +184,7 @@ pub async fn verify(token: &str, provider: &JwtProvider, store: &Store) -> Resul sub: claims.sub, iss: claims.iss, scopes, + org_id: claims.org_id, }) } diff --git a/components/mcp-authorizer/tests/src/lib.rs b/components/mcp-authorizer/tests/src/lib.rs index 521bbdc6..fb9228b0 100644 --- a/components/mcp-authorizer/tests/src/lib.rs +++ b/components/mcp-authorizer/tests/src/lib.rs @@ -20,6 +20,7 @@ mod jwt_test_utils_tests; mod test_token_utils; mod optional_issuer_tests; mod authkit_integration_tests; +mod tenant_validation_tests; mod test_helpers; mod simple_test; mod test_setup; diff --git a/components/mcp-authorizer/tests/src/tenant_validation_tests.rs b/components/mcp-authorizer/tests/src/tenant_validation_tests.rs new file mode 100644 index 00000000..fd59dc00 --- /dev/null +++ b/components/mcp-authorizer/tests/src/tenant_validation_tests.rs @@ -0,0 +1,256 @@ +//! Tests for tenant validation in private mode + +use spin_test_sdk::{ + bindings::{ + fermyon::spin_test_virt::variables, + fermyon::spin_wasi_virt::http_handler, + wasi::http::types, + }, + spin_test, +}; + +use crate::test_token_utils::{TestKeyPair, TestTokenBuilder}; + +/// Test tenant validation when mcp_tenant_id is set +#[spin_test] +fn test_tenant_validation_with_org_id() { + // Set up test key pair + let key_pair = TestKeyPair::generate(); + + // Configure provider with public key + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_jwt_issuer", "https://test.authkit.app"); + variables::set("mcp_jwt_public_key", &key_pair.public_key_pem()); + + // Set tenant ID (simulating private mode) + variables::set("mcp_tenant_id", "org_12345"); + + // Mock gateway response + let gateway_response = types::OutgoingResponse::new(types::Headers::new()); + gateway_response.set_status_code(200).unwrap(); + let headers = gateway_response.headers(); + headers.append("content-type", b"application/json").unwrap(); + let body = gateway_response.body().unwrap(); + body.write_bytes(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":1}"); + http_handler::set_response( + "https://test-gateway.spin.internal/mcp-internal", + http_handler::ResponseHandler::Response(gateway_response), + ); + + // Create token with matching org_id + let token = key_pair.create_token( + TestTokenBuilder::new() + .issuer("https://test.authkit.app") + .subject("user_abc") + .claim("org_id", serde_json::json!("org_12345")) + ); + + let headers = types::Headers::new(); + headers.append("authorization", format!("Bearer {}", token).as_bytes()).unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 200, "Should accept token with matching org_id"); +} + +/// Test tenant validation falls back to sub when no org_id +#[spin_test] +fn test_tenant_validation_with_sub_fallback() { + // Set up test key pair + let key_pair = TestKeyPair::generate(); + + // Configure provider with public key + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_jwt_issuer", "https://test.authkit.app"); + variables::set("mcp_jwt_public_key", &key_pair.public_key_pem()); + + // Set tenant ID to a user ID (simulating private mode for individual) + variables::set("mcp_tenant_id", "user_12345"); + + // Mock gateway response + let gateway_response = types::OutgoingResponse::new(types::Headers::new()); + gateway_response.set_status_code(200).unwrap(); + let headers = gateway_response.headers(); + headers.append("content-type", b"application/json").unwrap(); + let body = gateway_response.body().unwrap(); + body.write_bytes(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":1}"); + http_handler::set_response( + "https://test-gateway.spin.internal/mcp-internal", + http_handler::ResponseHandler::Response(gateway_response), + ); + + // Create token without org_id + let token = key_pair.create_token( + TestTokenBuilder::new() + .issuer("https://test.authkit.app") + .subject("user_12345") + ); + + let headers = types::Headers::new(); + headers.append("authorization", format!("Bearer {}", token).as_bytes()).unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 200, "Should accept token with matching sub when no org_id"); +} + +/// Test tenant validation rejects mismatched org_id +#[spin_test] +fn test_tenant_validation_rejects_wrong_org() { + // Set up test key pair + let key_pair = TestKeyPair::generate(); + + // Configure provider with public key + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_jwt_issuer", "https://test.authkit.app"); + variables::set("mcp_jwt_public_key", &key_pair.public_key_pem()); + + // Set tenant ID (simulating private mode) + variables::set("mcp_tenant_id", "org_12345"); + + // Create token with different org_id + let token = key_pair.create_token( + TestTokenBuilder::new() + .issuer("https://test.authkit.app") + .subject("user_abc") + .claim("org_id", serde_json::json!("org_99999")) + ); + + let headers = types::Headers::new(); + headers.append("authorization", format!("Bearer {}", token).as_bytes()).unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 401, "Should reject token with mismatched org_id"); + + // Check error message + let body = response.body().unwrap_or_default(); + let body_str = String::from_utf8_lossy(&body); + assert!(body_str.contains("invalid tenant"), "Error should mention tenant mismatch"); +} + +/// Test tenant validation rejects mismatched sub when used as fallback +#[spin_test] +fn test_tenant_validation_rejects_wrong_sub() { + // Set up test key pair + let key_pair = TestKeyPair::generate(); + + // Configure provider with public key + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_jwt_issuer", "https://test.authkit.app"); + variables::set("mcp_jwt_public_key", &key_pair.public_key_pem()); + + // Set tenant ID to a user ID + variables::set("mcp_tenant_id", "user_12345"); + + // Create token with different sub and no org_id + let token = key_pair.create_token( + TestTokenBuilder::new() + .issuer("https://test.authkit.app") + .subject("user_99999") + ); + + let headers = types::Headers::new(); + headers.append("authorization", format!("Bearer {}", token).as_bytes()).unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 401, "Should reject token with mismatched sub"); +} + +/// Test no tenant validation when mcp_tenant_id is not set (custom mode) +#[spin_test] +fn test_no_tenant_validation_when_not_configured() { + // Set up test key pair + let key_pair = TestKeyPair::generate(); + + // Configure provider with public key + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_jwt_issuer", "https://test.authkit.app"); + variables::set("mcp_jwt_public_key", &key_pair.public_key_pem()); + + // Don't set mcp_tenant_id (simulating custom mode) + variables::set("mcp_tenant_id", ""); + + // Mock gateway response + let gateway_response = types::OutgoingResponse::new(types::Headers::new()); + gateway_response.set_status_code(200).unwrap(); + let headers = gateway_response.headers(); + headers.append("content-type", b"application/json").unwrap(); + let body = gateway_response.body().unwrap(); + body.write_bytes(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":1}"); + http_handler::set_response( + "https://test-gateway.spin.internal/mcp-internal", + http_handler::ResponseHandler::Response(gateway_response), + ); + + // Create token with any org_id + let token = key_pair.create_token( + TestTokenBuilder::new() + .issuer("https://test.authkit.app") + .subject("user_abc") + .claim("org_id", serde_json::json!("org_any")) + ); + + let headers = types::Headers::new(); + headers.append("authorization", format!("Bearer {}", token).as_bytes()).unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 200, "Should accept any token when tenant validation is disabled"); +} + +/// Test static token provider with org_id +#[spin_test] +fn test_static_token_with_org_id() { + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_provider_type", "static"); + variables::set("mcp_tenant_id", "org_static"); + + // Format: token:client_id:sub:scopes:org_id (when no expiration) + variables::set("mcp_static_tokens", "static-token-1:client1:user1:read,write:org_static"); + + // Mock gateway response + let gateway_response = types::OutgoingResponse::new(types::Headers::new()); + gateway_response.set_status_code(200).unwrap(); + let headers = gateway_response.headers(); + headers.append("content-type", b"application/json").unwrap(); + let body = gateway_response.body().unwrap(); + body.write_bytes(b"{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":1}"); + http_handler::set_response( + "https://test-gateway.spin.internal/mcp-internal", + http_handler::ResponseHandler::Response(gateway_response), + ); + + let headers = types::Headers::new(); + headers.append("authorization", b"Bearer static-token-1").unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 200, "Should accept static token with matching org_id"); +} + +/// Test static token rejected when org_id doesn't match +#[spin_test] +fn test_static_token_wrong_org_id() { + variables::set("mcp_gateway_url", "https://test-gateway.spin.internal/mcp-internal"); + variables::set("mcp_provider_type", "static"); + variables::set("mcp_tenant_id", "org_expected"); + + // Token has different org_id + variables::set("mcp_static_tokens", "static-token-2:client2:user2:read:org_different"); + + let headers = types::Headers::new(); + headers.append("authorization", b"Bearer static-token-2").unwrap(); + let request = types::OutgoingRequest::new(headers); + request.set_path_with_query(Some("/mcp")).unwrap(); + + let response = spin_test_sdk::perform_request(request); + assert_eq!(response.status(), 401, "Should reject static token with wrong org_id"); +} \ No newline at end of file diff --git a/components/mcp-authorizer/tests/src/test_token_utils.rs b/components/mcp-authorizer/tests/src/test_token_utils.rs index 0f25970c..60b26eac 100644 --- a/components/mcp-authorizer/tests/src/test_token_utils.rs +++ b/components/mcp-authorizer/tests/src/test_token_utils.rs @@ -164,13 +164,17 @@ impl TestTokenBuilder { } /// Build the claims - fn build(self) -> Claims { + fn build(mut self) -> Claims { let now = Utc::now(); let exp = match self.expires_in { Some(duration) => (now + duration).timestamp(), None => (now + Duration::hours(1)).timestamp(), // Default 1 hour }; + // Extract org_id from additional claims + let org_id = self.additional_claims.remove("org_id") + .and_then(|v| v.as_str().map(String::from)); + let claims = Claims { sub: self.subject.unwrap_or_else(|| "test-user".to_string()), iss: self.issuer.unwrap_or_else(|| "https://test.example.com".to_string()), @@ -180,9 +184,10 @@ impl TestTokenBuilder { nbf: None, client_id: self.client_id, scope: self.scopes.as_ref().map(|s| s.join(" ")), + org_id, }; - // Merge additional claims + // Merge remaining additional claims let mut claims_value = serde_json::to_value(&claims).unwrap(); if let serde_json::Value::Object(ref mut map) = claims_value { for (key, value) in self.additional_claims { @@ -209,6 +214,8 @@ struct Claims { scope: Option, #[serde(skip_serializing_if = "Option::is_none")] client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + org_id: Option, } /// Create a simple test token with minimal configuration diff --git a/crates/commands/src/commands/build_tests.rs b/crates/commands/src/commands/build_tests.rs index 4dc8a338..64b5f1ee 100644 --- a/crates/commands/src/commands/build_tests.rs +++ b/crates/commands/src/commands/build_tests.rs @@ -502,7 +502,12 @@ async fn test_build_invalid_toml() { assert!(result.is_err()); // The error will be about parsing ftl.toml now let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Failed to parse ftl.toml") || error_msg.contains("missing field")); + assert!( + error_msg.contains("Failed to parse FTL configuration") + || error_msg.contains("missing field") + || error_msg.contains("expected"), + "Unexpected error message: {error_msg}" + ); } #[tokio::test] diff --git a/crates/commands/src/commands/deploy.rs b/crates/commands/src/commands/deploy.rs index e005cee5..53a63383 100644 --- a/crates/commands/src/commands/deploy.rs +++ b/crates/commands/src/commands/deploy.rs @@ -172,12 +172,14 @@ async fn execute_deploy_inner( // If this is a dry run, display summary and exit if args.dry_run { spinner.finish_and_clear(); + let auth_config = resolve_auth_config(&deps.file_system, &args)?; + let auth_mode = auth_config.as_ref().map(|(mode, _, _, _)| mode); display_dry_run_summary( &deps, &config, use_release, &parsed_variables, - args.auth_mode.as_ref(), + auth_mode, &deploy_names, ); return Ok(()); @@ -236,8 +238,10 @@ async fn execute_deploy_inner( ) .await?; - // Update auth configuration BEFORE deployment if provided - if let Some(auth_mode) = &args.auth_mode { + // Update auth configuration BEFORE deployment + // This follows the hierarchy: CLI flags > env vars > ftl.toml + let auth_config = resolve_auth_config(&deps.file_system, &args)?; + if let Some((mode, provider, issuer, audience)) = auth_config { deps.ui.print(""); deps.ui.print_styled( "→ Configuring MCP authorization settings...", @@ -247,16 +251,15 @@ async fn execute_deploy_inner( update_auth_config( deps.clone(), &app_id.to_string(), - auth_mode, - args.auth_users.as_ref(), - args.auth_provider.as_ref(), - args.auth_issuer.as_ref(), - args.auth_audience.as_ref(), + &mode, + provider.as_ref(), + issuer.as_ref(), + audience.as_ref(), ) .await?; deps.ui.print_styled( - &format!("✓ MCP authorization set to: {auth_mode}"), + &format!("✓ MCP authorization set to: {mode}"), MessageStyle::Warning, ); } @@ -367,6 +370,25 @@ pub fn parse_variables(variables: &[String]) -> Result> pub(crate) fn is_sensitive_variable(name: &str) -> bool { let name_lower = name.to_lowercase(); + // Specific non-sensitive auth configuration variables + let config_exceptions = [ + "auth_enabled", + "mcp_jwt_issuer", + "mcp_jwt_audience", + "mcp_jwt_required_scopes", + "mcp_jwt_algorithm", + "mcp_provider_type", + "mcp_jwt_jwks_uri", + "mcp_oauth_authorize_endpoint", + "mcp_oauth_token_endpoint", + "mcp_oauth_userinfo_endpoint", + ]; + + // If it's a known configuration variable, it's not sensitive + if config_exceptions.iter().any(|&exc| name_lower == exc) { + return false; + } + // Common patterns for sensitive variable names let sensitive_patterns = [ "token", @@ -495,44 +517,32 @@ fn display_dry_run_summary( async fn update_auth_config( deps: Arc, app_id: &str, - auth_mode: &str, - auth_users: Option<&String>, + access_control_mode: &str, auth_provider: Option<&String>, auth_issuer: Option<&String>, auth_audience: Option<&String>, ) -> Result<()> { - use types::UpdateAuthConfigRequestMode; + use types::UpdateAuthConfigRequestAccessControl; - let mode = match auth_mode { - "public" => UpdateAuthConfigRequestMode::Public, - "ftl-account" => UpdateAuthConfigRequestMode::FtlAccount, - "user-only" => UpdateAuthConfigRequestMode::UserOnly, - "custom" => UpdateAuthConfigRequestMode::Custom, + let access_control = match access_control_mode { + "public" => UpdateAuthConfigRequestAccessControl::Public, + "private" => UpdateAuthConfigRequestAccessControl::Private, + "custom" => UpdateAuthConfigRequestAccessControl::Custom, _ => { return Err(anyhow!( - "Invalid auth mode: {}. Must be one of: public, ftl-account, user-only, custom", - auth_mode + "Invalid access control mode: {}. Must be one of: public, private, custom", + access_control_mode )); } }; - let mut allowed_users = vec![]; let mut custom_config = None; - // Handle different auth modes - match auth_mode { - "public" | "ftl-account" => { + // Handle different access control modes + match access_control_mode { + "public" | "private" => { // No additional config needed } - "user-only" => { - if let Some(users) = auth_users { - allowed_users = users - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - } "custom" => { if auth_provider.is_none() || auth_issuer.is_none() { return Err(anyhow!( @@ -559,10 +569,8 @@ async fn update_auth_config( } let request = types::UpdateAuthConfigRequest { - mode, + access_control, custom_config, - allowed_users, - allowed_tenants: vec![], }; deps.api_client @@ -583,78 +591,87 @@ fn add_auth_variables_from_ftl( let content = file_system.read_to_string(Path::new("ftl.toml"))?; let config = FtlConfig::parse(&content)?; - // Only add auth variables if they're not already provided via command line - if config.auth.enabled && !variables.contains_key("auth_enabled") { - variables.insert("auth_enabled".to_string(), "true".to_string()); - } - - let provider_type = config.auth.provider_type(); - if !provider_type.is_empty() && !variables.contains_key("mcp_provider_type") { - variables.insert("mcp_provider_type".to_string(), provider_type.to_string()); - } - - let issuer = config.auth.issuer(); - if !issuer.is_empty() && !variables.contains_key("mcp_jwt_issuer") { - variables.insert("mcp_jwt_issuer".to_string(), issuer.to_string()); - } - - let audience = config.auth.audience(); - if !audience.is_empty() && !variables.contains_key("mcp_jwt_audience") { - variables.insert("mcp_jwt_audience".to_string(), audience.to_string()); - } - - let required_scopes = config.auth.required_scopes(); - if !required_scopes.is_empty() && !variables.contains_key("mcp_jwt_required_scopes") { + // Always add auth_enabled variable if not already provided via command line + if !variables.contains_key("auth_enabled") { variables.insert( - "mcp_jwt_required_scopes".to_string(), - required_scopes.to_string(), + "auth_enabled".to_string(), + config.is_auth_enabled().to_string(), ); } - // Add OIDC-specific variables if present - if let Some(oidc) = &config.auth.oidc { - if !oidc.jwks_uri.is_empty() && !variables.contains_key("mcp_jwt_jwks_uri") { - variables.insert("mcp_jwt_jwks_uri".to_string(), oidc.jwks_uri.clone()); + // Only add other auth-related variables if auth is enabled + if config.is_auth_enabled() { + // Add provider type + if !variables.contains_key("mcp_provider_type") { + variables.insert( + "mcp_provider_type".to_string(), + config.auth_provider_type().to_string(), + ); } - if !oidc.public_key.is_empty() && !variables.contains_key("mcp_jwt_public_key") { - variables.insert("mcp_jwt_public_key".to_string(), oidc.public_key.clone()); + // Add issuer + let issuer = config.auth_issuer(); + if !issuer.is_empty() && !variables.contains_key("mcp_jwt_issuer") { + variables.insert("mcp_jwt_issuer".to_string(), issuer.to_string()); } - if !oidc.algorithm.is_empty() && !variables.contains_key("mcp_jwt_algorithm") { - variables.insert("mcp_jwt_algorithm".to_string(), oidc.algorithm.clone()); + // Add audience + let audience = config.auth_audience(); + if !audience.is_empty() && !variables.contains_key("mcp_jwt_audience") { + variables.insert("mcp_jwt_audience".to_string(), audience.to_string()); } - if !oidc.authorize_endpoint.is_empty() - && !variables.contains_key("mcp_oauth_authorize_endpoint") - { + // Add required scopes + let required_scopes = config.auth_required_scopes(); + if !required_scopes.is_empty() && !variables.contains_key("mcp_jwt_required_scopes") { variables.insert( - "mcp_oauth_authorize_endpoint".to_string(), - oidc.authorize_endpoint.clone(), + "mcp_jwt_required_scopes".to_string(), + required_scopes.to_string(), ); } + } - if !oidc.token_endpoint.is_empty() && !variables.contains_key("mcp_oauth_token_endpoint") { - variables.insert( - "mcp_oauth_token_endpoint".to_string(), - oidc.token_endpoint.clone(), - ); - } + // Add OIDC-specific variables if present and auth is enabled + if config.is_auth_enabled() { + if let Some(oidc) = &config.oidc { + if !oidc.jwks_uri.is_empty() && !variables.contains_key("mcp_jwt_jwks_uri") { + variables.insert("mcp_jwt_jwks_uri".to_string(), oidc.jwks_uri.clone()); + } - if !oidc.userinfo_endpoint.is_empty() - && !variables.contains_key("mcp_oauth_userinfo_endpoint") - { - variables.insert( - "mcp_oauth_userinfo_endpoint".to_string(), - oidc.userinfo_endpoint.clone(), - ); - } - } + if !oidc.public_key.is_empty() && !variables.contains_key("mcp_jwt_public_key") { + variables.insert("mcp_jwt_public_key".to_string(), oidc.public_key.clone()); + } - // Add static token provider variables if present - if let Some(static_token) = &config.auth.static_token { - if !static_token.tokens.is_empty() && !variables.contains_key("mcp_static_tokens") { - variables.insert("mcp_static_tokens".to_string(), static_token.tokens.clone()); + if !oidc.algorithm.is_empty() && !variables.contains_key("mcp_jwt_algorithm") { + variables.insert("mcp_jwt_algorithm".to_string(), oidc.algorithm.clone()); + } + + if !oidc.authorize_endpoint.is_empty() + && !variables.contains_key("mcp_oauth_authorize_endpoint") + { + variables.insert( + "mcp_oauth_authorize_endpoint".to_string(), + oidc.authorize_endpoint.clone(), + ); + } + + if !oidc.token_endpoint.is_empty() + && !variables.contains_key("mcp_oauth_token_endpoint") + { + variables.insert( + "mcp_oauth_token_endpoint".to_string(), + oidc.token_endpoint.clone(), + ); + } + + if !oidc.userinfo_endpoint.is_empty() + && !variables.contains_key("mcp_oauth_userinfo_endpoint") + { + variables.insert( + "mcp_oauth_userinfo_endpoint".to_string(), + oidc.userinfo_endpoint.clone(), + ); + } } } @@ -1171,10 +1188,8 @@ async fn deploy_to_ftl_with_progress( pub struct DeployArgs { /// Variable(s) to be passed to the app (KEY=VALUE format) pub variables: Vec, - /// Authentication mode to set for the deployed app - pub auth_mode: Option, - /// Allowed users for user-only mode (comma-separated) - pub auth_users: Option, + /// Access control mode to set for the deployed app + pub access_control: Option, /// Custom auth provider (authkit, auth0, oidc) pub auth_provider: Option, /// Custom auth issuer URL @@ -1185,6 +1200,94 @@ pub struct DeployArgs { pub dry_run: bool, } +/// Auth configuration resolved from various sources +type ResolvedAuthConfig = (String, Option, Option, Option); + +/// Resolve auth configuration following hierarchy: CLI flags > env vars > ftl.toml +fn resolve_auth_config( + file_system: &Arc, + args: &DeployArgs, +) -> Result> { + use crate::config::ftl_config::FtlConfig; + + // Start with ftl.toml as the base + let mut auth_mode = None; + let mut auth_provider = None; + let mut auth_issuer = None; + let mut auth_audience = None; + + // Load from ftl.toml if it exists + if file_system.exists(Path::new("ftl.toml")) { + let content = file_system.read_to_string(Path::new("ftl.toml"))?; + let config = FtlConfig::parse(&content)?; + + // Determine auth mode based on configuration + if config.project.access_control == "public" { + auth_mode = Some("public".to_string()); + } else if config.project.access_control == "private" { + // Check if we have custom OIDC config + if config.oidc.is_some() { + auth_mode = Some("custom".to_string()); + } else { + auth_mode = Some("private".to_string()); + } + } + + // Extract provider details only when auth is enabled + if config.is_auth_enabled() { + // Extract provider details based on configuration + if let Some(oidc) = &config.oidc { + auth_provider = Some("oidc".to_string()); + auth_issuer = Some(oidc.issuer.clone()); + auth_audience = if oidc.audience.is_empty() { + None + } else { + Some(oidc.audience.clone()) + }; + } else { + // Using FTL's built-in AuthKit + auth_provider = Some("jwt".to_string()); + auth_issuer = Some(config.auth_issuer().to_string()); + auth_audience = None; // FTL AuthKit doesn't use audience + } + } + } + + // Override with environment variables if set + if let Ok(mode) = std::env::var("FTL_ACCESS_CONTROL") { + auth_mode = Some(mode); + } + if let Ok(provider) = std::env::var("FTL_AUTH_PROVIDER") { + auth_provider = Some(provider); + } + if let Ok(issuer) = std::env::var("FTL_AUTH_ISSUER") { + auth_issuer = Some(issuer); + } + if let Ok(audience) = std::env::var("FTL_AUTH_AUDIENCE") { + auth_audience = Some(audience); + } + + // Override with CLI flags (highest priority) + if let Some(mode) = &args.access_control { + auth_mode = Some(mode.clone()); + } + if let Some(provider) = &args.auth_provider { + auth_provider = Some(provider.clone()); + } + if let Some(issuer) = &args.auth_issuer { + auth_issuer = Some(issuer.clone()); + } + if let Some(audience) = &args.auth_audience { + auth_audience = Some(audience.clone()); + } + + // Return None if no auth mode is configured + match auth_mode { + Some(mode) => Ok(Some((mode, auth_provider, auth_issuer, auth_audience))), + None => Ok(None), + } +} + // Build executor implementation struct BuildExecutorImpl; diff --git a/crates/commands/src/commands/deploy_tests.rs b/crates/commands/src/commands/deploy_tests.rs index b46ebd30..8a0d622a 100644 --- a/crates/commands/src/commands/deploy_tests.rs +++ b/crates/commands/src/commands/deploy_tests.rs @@ -101,8 +101,9 @@ fn setup_project_file_mocks(fixture: &mut TestFixture, has_ftl_toml: bool) { /// 2. `generate_temp_spin_toml` reading ./ftl.toml /// 3. deploy reading ftl.toml for `FtlConfig` parsing /// 4. variable loading checking and reading ftl.toml -/// 5. `parse_deploy_config` reading the generated spin.toml -/// 6. Component version file checks +/// 5. `resolve_auth_config` checking and reading ftl.toml +/// 6. `parse_deploy_config` reading the generated spin.toml +/// 7. Component version file checks fn setup_comprehensive_ftl_mocks(fixture: &mut TestFixture, ftl_toml_content: &str) { // Parse ftl config to generate expected spin.toml content let ftl_config = crate::config::ftl_config::FtlConfig::parse(ftl_toml_content).unwrap(); @@ -482,6 +483,13 @@ async fn test_deploy_success() { setup_successful_push(&mut fixture); setup_successful_deployment(&mut fixture); + // Mock: update auth config + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); + let ui = fixture.ui.clone(); let deps = fixture.to_deps(); let result = execute_with_deps(deps, default_deploy_args()).await; @@ -655,6 +663,7 @@ async fn test_deployment_timeout() { // Setup all mocks for successful push setup_full_mocks(&mut fixture); setup_successful_push(&mut fixture); + setup_auth_config_update(&mut fixture); // Mock: list apps returns empty (app doesn't exist) fixture @@ -764,7 +773,7 @@ async fn test_deployment_failed_status() { .times(1) .returning(|_| { Ok(types::CreateAppResponse { - app_id: uuid::Uuid::new_v4(), + app_id: uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap(), app_name: "test-app".to_string(), status: types::CreateAppResponseStatus::Creating, created_at: "2024-01-01T00:00:00Z".to_string(), @@ -772,6 +781,9 @@ async fn test_deployment_failed_status() { }) }); + // Mock: auth config update + setup_auth_config_update(&mut fixture); + // Mock: create deployment succeeds fixture .api_client @@ -821,20 +833,20 @@ fn setup_basic_mocks(fixture: &mut TestFixture) { // Set up project files - ftl.toml exists with a tool setup_project_file_mocks(fixture, true); - // Mock: check for ftl.toml again when extracting auth variables + // Mock: check for ftl.toml for variables and auth config fixture .file_system .expect_exists() .with(eq(Path::new("ftl.toml"))) - .times(0..=1) + .times(0..=2) // May be called for variables and/or resolve_auth_config .returning(|_| true); - // Mock: read ftl.toml for variables extraction (might be called twice - once for general variables, once for auth) + // Mock: read ftl.toml for variables extraction and auth config fixture .file_system .expect_read_to_string() .with(eq(Path::new("ftl.toml"))) - .times(0..=2) + .times(0..=3) // Once for general variables, once for auth variables, once for resolve_auth_config .returning(|_| { Ok(r#"[project] name = "test-project" @@ -926,6 +938,15 @@ fn setup_full_mocks(fixture: &mut TestFixture) { .returning(|_| Ok(())); } +fn setup_auth_config_update(fixture: &mut TestFixture) { + // Mock: update auth config + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); +} + fn setup_successful_push(fixture: &mut TestFixture) { // Mock: update components succeeds and returns repository URIs fixture @@ -1285,7 +1306,7 @@ async fn test_deploy_with_variables() { .times(1) .returning(|_| { Ok(types::CreateAppResponse { - app_id: uuid::Uuid::new_v4(), + app_id: uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap(), app_name: "test-app".to_string(), status: types::CreateAppResponseStatus::Creating, created_at: "2024-01-01T00:00:00Z".to_string(), @@ -1293,6 +1314,9 @@ async fn test_deploy_with_variables() { }) }); + // Mock: auth config update + setup_auth_config_update(&mut fixture); + // Mock: create deployment with variables fixture .api_client @@ -1333,7 +1357,7 @@ async fn test_deploy_with_variables() { ) .await; - assert!(result.is_ok()); + assert!(result.is_ok(), "Error: {result:?}"); let output = ui.get_output(); assert!(output.iter().any(|s| s.contains("Deployed!"))); } @@ -1356,11 +1380,9 @@ async fn test_deploy_with_auth_from_ftl_toml() { [project] name = "test-app" version = "0.1.0" +access_control = "private" -[auth] -enabled = true - -[auth.authkit] +[oidc] issuer = "https://test.authkit.app" audience = "my-api" @@ -1392,21 +1414,21 @@ command = "cargo build --release --target wasm32-wasip1" .times(1) .returning(move |_| Ok(ftl_content_for_build.clone())); - // Mock: check for ftl.toml again when extracting auth variables + // Mock: check for ftl.toml again when extracting auth variables and resolving auth config fixture .file_system .expect_exists() .with(eq(Path::new("ftl.toml"))) - .times(1) + .times(2) // Once for variables, once for resolve_auth_config .returning(|_| true); - // Mock: read ftl.toml for variables extraction (both general and auth) + // Mock: read ftl.toml for variables extraction and auth config let ftl_content2 = ftl_content.clone(); fixture .file_system .expect_read_to_string() .with(eq(Path::new("ftl.toml"))) - .times(2) // Called twice: once for general variables, once for auth variables + .times(3) // Once for general variables, once for auth variables, once for resolve_auth_config .returning(move |_| Ok(ftl_content2.clone())); // Mock: component version files don't exist (use default) @@ -1491,6 +1513,50 @@ command = "cargo build --release --target wasm32-wasip1" }) }); + // Mock: update components + fixture + .api_client + .expect_update_components() + .times(1) + .returning(|_, _| { + Ok(types::UpdateComponentsResponse { + components: vec![types::UpdateComponentsResponseComponentsItem { + component_name: "api".to_string(), + description: None, + repository_uri: Some( + "123456789012.dkr.ecr.us-east-1.amazonaws.com/user/api".to_string(), + ), + repository_name: Some("user/api".to_string()), + }], + changes: types::UpdateComponentsResponseChanges { + created: vec!["api".to_string()], + updated: vec![], + removed: vec![], + }, + }) + }); + + // Mock: wkg push succeeds + fixture + .command_executor + .expect_execute() + .withf(|cmd: &str, args: &[&str]| cmd == "wkg" && args.contains(&"push")) + .times(1) + .returning(|_, _| { + Ok(CommandOutput { + success: true, + stdout: b"Pushed".to_vec(), + stderr: vec![], + }) + }); + + // Mock: update auth config based on ftl.toml (private mode) + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); + // Mock: create deployment with auth variables fixture .api_client @@ -1551,7 +1617,7 @@ command = "cargo build --release --target wasm32-wasip1" let deps = fixture.to_deps(); let result = execute_with_deps(deps, default_deploy_args()).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "Error: {result:?}"); let output = ui.get_output(); assert!(output.iter().any(|s| s.contains("Deployed!"))); } @@ -1574,11 +1640,9 @@ async fn test_deploy_cli_variables_override_ftl_toml() { [project] name = "test-app" version = "0.1.0" +access_control = "private" -[auth] -enabled = true - -[auth.authkit] +[oidc] issuer = "https://test.authkit.app" audience = "my-api" @@ -1610,21 +1674,21 @@ command = "cargo build --release --target wasm32-wasip1" .times(1) .returning(move |_| Ok(ftl_content_for_build.clone())); - // Mock: check for ftl.toml again when extracting auth variables + // Mock: check for ftl.toml again when extracting auth variables and resolving auth config fixture .file_system .expect_exists() .with(eq(Path::new("ftl.toml"))) - .times(1) + .times(2) // Once for variables, once for resolve_auth_config .returning(|_| true); - // Mock: read ftl.toml for variables extraction (both general and auth) + // Mock: read ftl.toml for variables extraction and auth config let ftl_content2 = ftl_content.clone(); fixture .file_system .expect_read_to_string() .with(eq(Path::new("ftl.toml"))) - .times(2) // Called twice: once for general variables, once for auth variables + .times(3) // Once for general variables, once for auth variables, once for resolve_auth_config .returning(move |_| Ok(ftl_content2.clone())); // Mock: component version files don't exist (use default) @@ -1709,6 +1773,50 @@ command = "cargo build --release --target wasm32-wasip1" }) }); + // Mock: update components + fixture + .api_client + .expect_update_components() + .times(1) + .returning(|_, _| { + Ok(types::UpdateComponentsResponse { + components: vec![types::UpdateComponentsResponseComponentsItem { + component_name: "api".to_string(), + description: None, + repository_uri: Some( + "123456789012.dkr.ecr.us-east-1.amazonaws.com/user/api".to_string(), + ), + repository_name: Some("user/api".to_string()), + }], + changes: types::UpdateComponentsResponseChanges { + created: vec!["api".to_string()], + updated: vec![], + removed: vec![], + }, + }) + }); + + // Mock: wkg push succeeds + fixture + .command_executor + .expect_execute() + .withf(|cmd: &str, args: &[&str]| cmd == "wkg" && args.contains(&"push")) + .times(1) + .returning(|_, _| { + Ok(CommandOutput { + success: true, + stdout: b"Pushed".to_vec(), + stderr: vec![], + }) + }); + + // Mock: update auth config based on ftl.toml (private mode) + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); + // Mock: create deployment with auth variables fixture .api_client @@ -1769,7 +1877,7 @@ command = "cargo build --release --target wasm32-wasip1" ) .await; - assert!(result.is_ok()); + assert!(result.is_ok(), "Error: {result:?}"); let output = ui.get_output(); assert!(output.iter().any(|s| s.contains("Deployed!"))); } @@ -1778,8 +1886,7 @@ command = "cargo build --release --target wasm32-wasip1" fn default_deploy_args() -> DeployArgs { DeployArgs { variables: vec![], - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -1791,8 +1898,7 @@ fn default_deploy_args() -> DeployArgs { fn deploy_args_with_variables(variables: Vec) -> DeployArgs { DeployArgs { variables, - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -1821,9 +1927,9 @@ async fn test_auth_config_updated_before_deployment() { .returning(move |app_id, request| { call_order_clone1.lock().unwrap().push("update_auth_config"); assert_eq!(app_id, "12345678-1234-1234-1234-123456789012"); - match request.mode { - types::UpdateAuthConfigRequestMode::Public => {} - _ => panic!("Expected public mode"), + match request.access_control { + types::UpdateAuthConfigRequestAccessControl::Public => {} + _ => panic!("Expected public access control"), } // We don't care about the response structure for this test // Just tracking that the call happened in the right order @@ -1893,8 +1999,7 @@ async fn test_auth_config_updated_before_deployment() { // Deploy with auth configuration let args = DeployArgs { variables: vec![], - auth_mode: Some("public".to_string()), - auth_users: None, + access_control: Some("public".to_string()), auth_provider: None, auth_issuer: None, auth_audience: None, @@ -1962,6 +2067,14 @@ fn test_is_sensitive_variable() { assert!(!is_sensitive_variable("timeout")); assert!(!is_sensitive_variable("max_retries")); assert!(!is_sensitive_variable("api_version")); + + // Test auth configuration exceptions (not sensitive) + assert!(!is_sensitive_variable("auth_enabled")); + assert!(!is_sensitive_variable("AUTH_ENABLED")); + assert!(!is_sensitive_variable("mcp_jwt_issuer")); + assert!(!is_sensitive_variable("mcp_jwt_audience")); + assert!(!is_sensitive_variable("mcp_provider_type")); + assert!(!is_sensitive_variable("mcp_jwt_jwks_uri")); } #[tokio::test] @@ -1971,6 +2084,9 @@ async fn test_deploy_with_sensitive_variables() { setup_successful_push(&mut fixture); setup_successful_deployment(&mut fixture); + // Mock: auth config update + setup_auth_config_update(&mut fixture); + let ui = fixture.ui.clone(); let deps = fixture.to_deps(); @@ -1984,8 +2100,7 @@ async fn test_deploy_with_sensitive_variables() { "jwt_secret=jwt-secret-value".to_string(), "debug_mode=false".to_string(), ], - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2030,6 +2145,9 @@ async fn test_deploy_with_short_sensitive_values() { setup_successful_push(&mut fixture); setup_successful_deployment(&mut fixture); + // Mock: auth config update + setup_auth_config_update(&mut fixture); + let ui = fixture.ui.clone(); let deps = fixture.to_deps(); @@ -2040,8 +2158,7 @@ async fn test_deploy_with_short_sensitive_values() { "token=xyz".to_string(), "secret=1234".to_string(), ], - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2071,20 +2188,20 @@ async fn test_deploy_dry_run() { // Setup project file mocks setup_project_file_mocks(&mut fixture, true); - // Mock: check for ftl.toml when extracting variables + // Mock: check for ftl.toml when extracting variables and auth config fixture .file_system .expect_exists() .with(eq(Path::new("ftl.toml"))) - .times(1) + .times(2) // Once for variables, once for resolve_auth_config .returning(|_| true); - // Mock: read ftl.toml for variables extraction (both general and auth) + // Mock: read ftl.toml for variables extraction and auth config fixture .file_system .expect_read_to_string() .with(eq(Path::new("ftl.toml"))) - .times(2) // Called twice: once for general variables, once for auth variables + .times(3) // Once for general variables, once for auth variables, once for resolve_auth_config .returning(|_| { Ok(r#"[project] name = "test-project" @@ -2140,8 +2257,7 @@ command = "echo 'Building test tool'" "api_url=https://api.example.com".to_string(), "debug_mode=true".to_string(), ], - auth_mode: Some("public".to_string()), - auth_users: None, + access_control: Some("public".to_string()), auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2167,7 +2283,7 @@ command = "echo 'Building test tool'" assert!(output.iter().any(|s| s.contains("test-tool"))); // Check variables section with proper redaction - assert!(output.iter().any(|s| s.contains("Variables (3):"))); + assert!(output.iter().any(|s| s.contains("Variables (4):"))); assert!(output.iter().any(|s| s.contains("🔒 api_token = te***"))); assert!( output @@ -2204,20 +2320,20 @@ async fn test_deploy_dry_run_no_variables() { // Setup project file mocks setup_project_file_mocks(&mut fixture, true); - // Mock: check for ftl.toml when extracting variables + // Mock: check for ftl.toml when extracting variables and auth config fixture .file_system .expect_exists() .with(eq(Path::new("ftl.toml"))) - .times(1) + .times(2) // Once for variables, once for resolve_auth_config .returning(|_| true); - // Mock: read ftl.toml for variables extraction (both general and auth) + // Mock: read ftl.toml for variables extraction and auth config fixture .file_system .expect_read_to_string() .with(eq(Path::new("ftl.toml"))) - .times(2) // Called twice: once for general variables, once for auth variables + .times(3) // Once for general variables, once for auth variables, once for resolve_auth_config .returning(|_| { Ok(r#"[project] name = "test-project" @@ -2264,8 +2380,7 @@ command = "echo 'Building test tool'" // Deploy with dry-run flag but no variables let args = DeployArgs { variables: vec![], - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2277,15 +2392,17 @@ command = "echo 'Building test tool'" let output = ui.get_output(); - // Should not show variables section when there are none - assert!(!output.iter().any(|s| s.contains("Variables"))); + // Should show variables section with auth_enabled (not redacted) + assert!(output.iter().any(|s| s.contains("Variables (1):"))); + assert!(output.iter().any(|s| s.contains(" auth_enabled = false"))); - // Should not show auth section when not configured + // Should show auth section with public mode assert!( - !output + output .iter() .any(|s| s.contains("Authorization Configuration:")) ); + assert!(output.iter().any(|s| s.contains("Mode: public"))); } #[tokio::test] @@ -2322,8 +2439,7 @@ async fn test_deploy_auth_mode_user_only() { let deps = fixture.to_deps(); let args = DeployArgs { variables: vec![], - auth_mode: Some("user-only".to_string()), - auth_users: Some("user1,user2,user3".to_string()), + access_control: Some("private".to_string()), auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2367,8 +2483,7 @@ async fn test_deploy_auth_mode_custom() { let deps = fixture.to_deps(); let args = DeployArgs { variables: vec![], - auth_mode: Some("custom".to_string()), - auth_users: None, + access_control: Some("custom".to_string()), auth_provider: Some("authkit".to_string()), auth_issuer: Some("https://auth.example.com".to_string()), auth_audience: Some("my-api".to_string()), @@ -2416,8 +2531,7 @@ async fn test_deploy_auth_mode_custom_missing_required() { let deps = fixture.to_deps(); let args = DeployArgs { variables: vec![], - auth_mode: Some("custom".to_string()), - auth_users: None, + access_control: Some("custom".to_string()), auth_provider: Some("authkit".to_string()), auth_issuer: None, // Missing required issuer auth_audience: None, @@ -2471,8 +2585,7 @@ async fn test_deploy_invalid_auth_mode() { let deps = fixture.to_deps(); let args = DeployArgs { variables: vec![], - auth_mode: Some("invalid-mode".to_string()), - auth_users: None, + access_control: Some("invalid-mode".to_string()), auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2485,7 +2598,7 @@ async fn test_deploy_invalid_auth_mode() { result .unwrap_err() .to_string() - .contains("Invalid auth mode") + .contains("Invalid access control mode") ); } @@ -2579,6 +2692,13 @@ command = "cargo build --release" setup_successful_deployment(&mut fixture); + // Mock: update auth config + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); + let deps = fixture.to_deps(); let result = execute_with_deps(deps, default_deploy_args()).await; assert!(result.is_ok()); @@ -2634,8 +2754,7 @@ command = "cargo build" // Dry run to test profile detection without full deployment let args = DeployArgs { variables: vec![], - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2689,8 +2808,7 @@ command = "echo 'Building test tool'" // Dry run to check variable handling let args = DeployArgs { variables: vec!["api_key=provided-key".to_string()], // Only provide one required var - auth_mode: None, - auth_users: None, + access_control: None, auth_provider: None, auth_issuer: None, auth_audience: None, @@ -2814,3 +2932,318 @@ async fn test_deploy_partial_component_push_failure() { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Failed to push")); } + +#[tokio::test] +async fn test_deploy_auth_enabled_always_included() { + let mut fixture = TestFixture::new(); + + // Setup basic mocks + setup_full_mocks(&mut fixture); + setup_successful_push(&mut fixture); + + // Mock: list apps returns empty + fixture + .api_client + .expect_list_apps() + .times(1) + .returning(|_, _, _| { + Ok(types::ListAppsResponse { + apps: vec![], + next_token: None, + }) + }); + + // Mock: create app + fixture + .api_client + .expect_create_app() + .times(1) + .returning(|_| { + Ok(types::CreateAppResponse { + app_id: uuid::Uuid::new_v4(), + app_name: "test-app".to_string(), + status: types::CreateAppResponseStatus::Creating, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }) + }); + + // Mock: update auth config - should be called even when auth is disabled + fixture + .api_client + .expect_update_auth_config() + .times(1) + .returning(|_, _| Ok(crate::test_helpers::test_auth_config_response())); + + // Mock: create deployment - verify auth_enabled is always present + fixture + .api_client + .expect_create_deployment() + .times(1) + .returning(|_, req| { + // This is the key assertion - auth_enabled should always be present + assert!( + req.variables.contains_key("auth_enabled"), + "auth_enabled must always be included in deployment variables" + ); + assert_eq!( + req.variables.get("auth_enabled"), + Some(&"false".to_string()), + "auth_enabled should be 'false' for public access control" + ); + + Ok(types::CreateDeploymentResponse { + deployment_id: uuid::Uuid::new_v4(), + app_id: uuid::Uuid::new_v4(), + app_name: "test-app".to_string(), + status: "DEPLOYING".to_string(), + message: "Deployment started".to_string(), + }) + }); + + // Mock: update components + fixture + .api_client + .expect_update_components() + .times(1) + .returning(|_, _| { + Ok(types::UpdateComponentsResponse { + components: vec![types::UpdateComponentsResponseComponentsItem { + component_name: "test-tool".to_string(), + description: None, + repository_uri: Some( + "123456789012.dkr.ecr.us-east-1.amazonaws.com/user/test-tool".to_string(), + ), + repository_name: Some("user/test-tool".to_string()), + }], + changes: types::UpdateComponentsResponseChanges { + created: vec!["test-tool".to_string()], + updated: vec![], + removed: vec![], + }, + }) + }); + + // Mock: app becomes active + fixture.api_client.expect_get_app().times(1).returning(|_| { + Ok(types::App { + app_id: uuid::Uuid::new_v4(), + app_name: "test-app".to_string(), + status: types::AppStatus::Active, + provider_url: Some("https://test-app.example.com".to_string()), + provider_error: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }) + }); + + let deps = fixture.to_deps(); + let args = DeployArgs { + variables: vec![], + access_control: None, // Public access control + auth_provider: None, + auth_issuer: None, + auth_audience: None, + dry_run: false, + }; + + let result = execute_with_deps(deps, args).await; + if let Err(e) = &result { + eprintln!("Test failed with error: {e}"); + } + assert!(result.is_ok()); +} + +#[test] +fn test_add_auth_variables_from_ftl() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Test 1: Public access control (auth disabled) + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"[project] +name = "test-app" +version = "0.1.0" +access_control = "public" +"# + ) + .unwrap(); + + // Remove unused variable + + // Mock reading ftl.toml + let content = std::fs::read_to_string(file.path()).unwrap(); + let mut mock_fs = MockFileSystemMock::new(); + mock_fs + .expect_read_to_string() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(move |_| Ok(content.clone())); + let fs_arc: Arc = Arc::new(mock_fs); + + let mut variables = HashMap::new(); + add_auth_variables_from_ftl(&fs_arc, &mut variables).unwrap(); + + // auth_enabled should be present and set to "false" + assert_eq!(variables.get("auth_enabled"), Some(&"false".to_string())); + // When auth is disabled, other auth variables should NOT be present + assert_eq!(variables.get("mcp_provider_type"), None); + assert_eq!(variables.get("mcp_jwt_issuer"), None); + + // Test 2: Private access control (auth enabled) + let mut file2 = NamedTempFile::new().unwrap(); + writeln!( + file2, + r#"[project] +name = "test-app" +version = "0.1.0" +access_control = "private" +"# + ) + .unwrap(); + + let content2 = std::fs::read_to_string(file2.path()).unwrap(); + let mut mock_fs2 = MockFileSystemMock::new(); + mock_fs2 + .expect_read_to_string() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(move |_| Ok(content2.clone())); + let fs_arc2: Arc = Arc::new(mock_fs2); + + let mut variables = HashMap::new(); + add_auth_variables_from_ftl(&fs_arc2, &mut variables).unwrap(); + + // auth_enabled should be present and set to "true" + assert_eq!(variables.get("auth_enabled"), Some(&"true".to_string())); + // Provider type should be "jwt" + assert_eq!(variables.get("mcp_provider_type"), Some(&"jwt".to_string())); + // Should use FTL's built-in AuthKit issuer + assert_eq!( + variables.get("mcp_jwt_issuer"), + Some(&"https://divine-lion-50-staging.authkit.app".to_string()) + ); + + // Test 3: Variables already provided via CLI should not be overwritten + let mut variables = HashMap::new(); + variables.insert("auth_enabled".to_string(), "custom_value".to_string()); + variables.insert( + "mcp_jwt_issuer".to_string(), + "https://custom.issuer.com".to_string(), + ); + + let content3 = std::fs::read_to_string(file2.path()).unwrap(); + let mut mock_fs3 = MockFileSystemMock::new(); + mock_fs3 + .expect_read_to_string() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(move |_| Ok(content3.clone())); + let fs_arc3: Arc = Arc::new(mock_fs3); + + add_auth_variables_from_ftl(&fs_arc3, &mut variables).unwrap(); + + // Existing values should be preserved + assert_eq!( + variables.get("auth_enabled"), + Some(&"custom_value".to_string()) + ); + assert_eq!( + variables.get("mcp_jwt_issuer"), + Some(&"https://custom.issuer.com".to_string()) + ); +} + +#[test] +fn test_resolve_auth_config_public_access() { + use crate::commands::deploy::DeployArgs; + use crate::commands::deploy::resolve_auth_config; + use std::io::Write; + use tempfile::NamedTempFile; + + // Test 1: Public access control in ftl.toml should be resolved + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"[project] +name = "test-app" +version = "0.1.0" +access_control = "public" +"# + ) + .unwrap(); + + let content = std::fs::read_to_string(file.path()).unwrap(); + let mut mock_fs = MockFileSystemMock::new(); + mock_fs + .expect_exists() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(|_| true); + mock_fs + .expect_read_to_string() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(move |_| Ok(content.clone())); + let fs_arc: Arc = Arc::new(mock_fs); + + let args = DeployArgs { + variables: vec![], + access_control: None, + auth_provider: None, + auth_issuer: None, + auth_audience: None, + dry_run: false, + }; + + let result = resolve_auth_config(&fs_arc, &args).unwrap(); + + // Should resolve to public mode + assert!(result.is_some()); + let (mode, provider, issuer, audience) = result.unwrap(); + assert_eq!(mode, "public"); + assert!(provider.is_none()); + assert!(issuer.is_none()); + assert!(audience.is_none()); + + // Test 2: Private access control should include auth details + let mut file2 = NamedTempFile::new().unwrap(); + writeln!( + file2, + r#"[project] +name = "test-app" +version = "0.1.0" +access_control = "private" +"# + ) + .unwrap(); + + let content2 = std::fs::read_to_string(file2.path()).unwrap(); + let mut mock_fs2 = MockFileSystemMock::new(); + mock_fs2 + .expect_exists() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(|_| true); + mock_fs2 + .expect_read_to_string() + .with(eq(Path::new("ftl.toml"))) + .times(1) + .returning(move |_| Ok(content2.clone())); + let fs_arc2: Arc = Arc::new(mock_fs2); + + let result2 = resolve_auth_config(&fs_arc2, &args).unwrap(); + + // Should resolve to private mode with FTL AuthKit details + assert!(result2.is_some()); + let (mode, provider, issuer, audience) = result2.unwrap(); + assert_eq!(mode, "private"); + assert_eq!(provider, Some("jwt".to_string())); + assert_eq!( + issuer, + Some("https://divine-lion-50-staging.authkit.app".to_string()) + ); + assert!(audience.is_none()); +} diff --git a/crates/commands/src/commands/init.rs b/crates/commands/src/commands/init.rs index eb676aec..a96c071b 100644 --- a/crates/commands/src/commands/init.rs +++ b/crates/commands/src/commands/init.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::{Context, Result, ensure}; -use crate::config::ftl_config::{AuthConfig, FtlConfig, McpConfig, ProjectConfig}; +use crate::config::ftl_config::{FtlConfig, McpConfig, ProjectConfig}; use ftl_common::{RealUserInterface, SpinInstaller, check_and_install_spin}; use ftl_runtime::deps::{ CommandExecutor, FileSystem, RealCommandExecutor, RealFileSystem, UserInterface, @@ -165,8 +165,9 @@ fn create_ftl_project( version: "0.1.0".to_string(), description: "FTL MCP server for hosting MCP tools".to_string(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), diff --git a/crates/commands/src/commands/up_tests.rs b/crates/commands/src/commands/up_tests.rs index d1169478..16b54635 100644 --- a/crates/commands/src/commands/up_tests.rs +++ b/crates/commands/src/commands/up_tests.rs @@ -647,7 +647,12 @@ async fn test_up_watch_mode_initial_build_fails() { assert!(result.is_err()); // Error should be about parsing ftl.toml let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Failed to parse ftl.toml") || error_msg.contains("missing field")); + assert!( + error_msg.contains("Failed to parse FTL configuration") + || error_msg.contains("missing field") + || error_msg.contains("expected"), + "Unexpected error message: {error_msg}" + ); } #[tokio::test] diff --git a/crates/commands/src/config/ftl_config.rs b/crates/commands/src/config/ftl_config.rs index 91c6388b..e0421c31 100644 --- a/crates/commands/src/config/ftl_config.rs +++ b/crates/commands/src/config/ftl_config.rs @@ -15,10 +15,10 @@ pub struct FtlConfig { #[garde(dive)] pub project: ProjectConfig, - /// Authorization configuration + /// OIDC configuration (optional) #[serde(default)] #[garde(dive)] - pub auth: AuthConfig, + pub oidc: Option, /// Tool definitions #[serde(default)] @@ -74,48 +74,13 @@ pub struct ProjectConfig { #[serde(default)] #[garde(skip)] pub authors: Vec, -} - -/// Authorization configuration -#[derive(Debug, Clone, Serialize, Deserialize, Default, Validate)] -#[garde(allow_unvalidated)] -pub struct AuthConfig { - /// Whether authorization is enabled - #[serde(default)] - pub enabled: bool, - - /// `AuthKit` configuration (mutually exclusive with oidc and static) - #[serde(default)] - #[garde(dive)] - pub authkit: Option, - /// OIDC configuration (mutually exclusive with authkit and static) - #[serde(default)] - #[garde(dive)] - pub oidc: Option, - - /// Static token configuration (mutually exclusive with authkit and oidc) - #[serde(default)] - #[garde(dive)] - pub static_token: Option, -} - -/// AuthKit-specific configuration -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct AuthKitConfig { - /// `AuthKit` issuer URL (e.g., ``) - #[garde(length(min = 1))] - pub issuer: String, - - /// API audience - #[serde(default)] - #[garde(skip)] - pub audience: String, - - /// Required scopes (comma-separated) - #[serde(default)] - #[garde(skip)] - pub required_scopes: String, + /// Access control mode: "public" or "private" + /// - public: No authentication required (default) + /// - private: Authentication required + #[serde(default = "default_access_control")] + #[garde(custom(validate_access_control))] + pub access_control: String, } /// OIDC-specific configuration @@ -166,21 +131,6 @@ pub struct OidcConfig { pub userinfo_endpoint: String, } -/// Static token configuration (for development/testing) -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct StaticTokenConfig { - /// Static token definitions - /// Format: "`token:client_id:sub:scope1,scope2[:expires_at]`" - /// Multiple tokens separated by semicolons - #[garde(length(min = 1))] - pub tokens: String, - - /// Required scopes (comma-separated) - #[serde(default)] - #[garde(skip)] - pub required_scopes: String, -} - /// Deployment configuration for a tool #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct DeployConfig { @@ -292,117 +242,82 @@ pub struct BuildConfig { /// MCP component configuration #[derive(Debug, Clone, Serialize, Deserialize, Validate)] -#[garde(allow_unvalidated)] pub struct McpConfig { - /// Gateway component registry URI - /// Example: "ghcr.io/fastertools/mcp-gateway:0.0.10" - #[serde(default = "default_gateway_uri")] + /// MCP gateway component registry URI + #[serde(default = "default_gateway")] + #[garde(length(min = 1))] pub gateway: String, /// MCP authorizer component registry URI - /// Example: "ghcr.io/fastertools/mcp-authorizer:0.0.10" - #[serde(default = "default_authorizer_uri")] + #[serde(default = "default_authorizer")] + #[garde(length(min = 1))] pub authorizer: String, - /// Whether to validate tool arguments - #[serde(default = "default_true")] + /// Whether to validate tool call arguments + #[serde(default = "default_validate_arguments")] + #[garde(skip)] pub validate_arguments: bool, } -fn default_version() -> String { - "0.1.0".to_string() -} - -fn default_gateway_uri() -> String { - "ghcr.io/fastertools/mcp-gateway:0.0.10".to_string() -} - -fn default_authorizer_uri() -> String { - "ghcr.io/fastertools/mcp-authorizer:0.0.10".to_string() -} - -const fn default_true() -> bool { - true -} +impl FtlConfig { + /// Parse FTL configuration from TOML string + pub fn parse(content: &str) -> Result { + let config: Self = toml::from_str(content).context("Failed to parse FTL configuration")?; + config + .validate() + .context("FTL configuration validation failed")?; + Ok(config) + } -impl Default for McpConfig { - fn default() -> Self { - Self { - gateway: default_gateway_uri(), - authorizer: default_authorizer_uri(), - validate_arguments: default_true(), - } + /// Serialize FTL configuration to TOML string + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).context("Failed to serialize FTL configuration") } -} -// Custom validation functions for garde -#[allow(clippy::trivially_copy_pass_by_ref)] -fn validate_tools(tools: &HashMap, _ctx: &()) -> garde::Result { - for (name, tool) in tools { - // Validate tool names - if !name - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return Err(garde::Error::new(format!( - "Tool name '{name}' can only contain alphanumeric characters, hyphens, and underscores" - ))); - } - if !name.chars().next().is_some_and(char::is_alphabetic) { - return Err(garde::Error::new(format!( - "Tool name '{name}' must start with a letter" - ))); - } + /// Get the list of tool component names + pub fn tool_components(&self) -> Vec { + self.tools.keys().cloned().collect() + } - // Validate each tool - tool.validate() - .map_err(|e| garde::Error::new(format!("Tool '{name}': {e}")))?; + /// Determine if authentication is enabled + pub fn is_auth_enabled(&self) -> bool { + self.project.access_control == "private" } - Ok(()) -} -impl AuthConfig { - /// Get the provider type as a string - pub const fn provider_type(&self) -> &str { - if self.authkit.is_some() || self.oidc.is_some() { - "jwt" // Both AuthKit and OIDC use JWT provider - } else if self.static_token.is_some() { - "static" + /// Determine the auth provider type + pub fn auth_provider_type(&self) -> &str { + if self.is_auth_enabled() { + "jwt" // Always use JWT for both OIDC and built-in AuthKit } else { "" } } /// Get the issuer URL - pub fn issuer(&self) -> &str { - if let Some(authkit) = &self.authkit { - &authkit.issuer - } else if let Some(oidc) = &self.oidc { + pub fn auth_issuer(&self) -> &str { + if let Some(oidc) = &self.oidc { &oidc.issuer + } else if self.is_auth_enabled() { + // Use FTL's built-in AuthKit + "https://divine-lion-50-staging.authkit.app" } else { "" } } /// Get the audience - pub fn audience(&self) -> &str { - if let Some(authkit) = &self.authkit { - &authkit.audience - } else if let Some(oidc) = &self.oidc { + pub fn auth_audience(&self) -> &str { + if let Some(oidc) = &self.oidc { &oidc.audience } else { "" } } - /// Get the required scopes - pub fn required_scopes(&self) -> &str { - if let Some(authkit) = &self.authkit { - &authkit.required_scopes - } else if let Some(oidc) = &self.oidc { + /// Get required scopes + pub fn auth_required_scopes(&self) -> &str { + if let Some(oidc) = &self.oidc { &oidc.required_scopes - } else if let Some(static_token) = &self.static_token { - &static_token.required_scopes } else { "" } @@ -410,108 +325,71 @@ impl AuthConfig { } impl ToolConfig { - /// Get the effective path for the tool (uses tool name if path not specified) + /// Get the path to the tool directory pub fn get_path(&self, tool_name: &str) -> String { self.path.clone().unwrap_or_else(|| tool_name.to_string()) } +} - /// Get the build command for a specific profile - pub fn get_build_command(&self, profile: Option<&str>) -> &str { - if let Some(profile_name) = profile { - if let Some(profiles) = &self.profiles { - if let Some(profile) = profiles.profiles.get(profile_name) { - return &profile.command; - } - } - } - // Fall back to default build command - &self.build.command - } +// Default value functions +fn default_version() -> String { + "0.1.0".to_string() +} - /// Get the build configuration for a specific profile - pub fn get_build_config(&self, profile: Option<&str>) -> BuildProfile { - if let Some(profile_name) = profile { - if let Some(profiles) = &self.profiles { - if let Some(profile) = profiles.profiles.get(profile_name) { - return profile.clone(); - } - } - } - // Fall back to default build config - BuildProfile { - command: self.build.command.clone(), - watch: self.build.watch.clone(), - env: self.build.env.clone(), - } - } +fn default_access_control() -> String { + "public".to_string() +} - /// Get watch paths for a specific profile - pub fn get_watch_paths(&self, profile: Option<&str>) -> Vec { - if let Some(profile_name) = profile { - if let Some(profiles) = &self.profiles { - if let Some(profile) = profiles.profiles.get(profile_name) { - return profile.watch.clone(); - } - } - } - // Fall back to default watch paths - self.build.watch.clone() - } +fn default_gateway() -> String { + "ghcr.io/fastertools/mcp-gateway:0.0.10".to_string() +} - /// Get the profile to use for 'ftl up' - pub fn get_up_profile(&self) -> Option<&str> { - self.up.as_ref().map(|up| up.profile.as_str()) - } +fn default_authorizer() -> String { + "ghcr.io/fastertools/mcp-authorizer:0.0.10".to_string() } -impl FtlConfig { - /// Load FTL configuration from a TOML string - pub fn parse(content: &str) -> Result { - let config: Self = toml::from_str(content).context("Failed to parse ftl.toml")?; +const fn default_validate_arguments() -> bool { + false +} - // Use garde validation - config - .validate() - .map_err(|e| anyhow::anyhow!("Validation error: {}", e))?; - - // Additional auth validation - if config.auth.enabled { - match ( - &config.auth.authkit, - &config.auth.oidc, - &config.auth.static_token, - ) { - (None, None, None) => { - return Err(anyhow::anyhow!( - "Either 'authkit', 'oidc', or 'static_token' configuration must be provided when auth is enabled" - )); - } - (Some(_), Some(_), _) | (Some(_), _, Some(_)) | (_, Some(_), Some(_)) => { - return Err(anyhow::anyhow!( - "Only one of 'authkit', 'oidc', or 'static_token' can be configured at a time" - )); - } - _ => {} // One provider configured, which is correct - } +impl Default for McpConfig { + fn default() -> Self { + Self { + gateway: default_gateway(), + authorizer: default_authorizer(), + validate_arguments: default_validate_arguments(), } - - Ok(config) } +} - /// Load FTL configuration from a file - pub fn from_file(path: &std::path::Path) -> Result { - let content = std::fs::read_to_string(path).context("Failed to read ftl.toml")?; - Self::parse(&content) - } +// Validation functions +#[allow(clippy::trivially_copy_pass_by_ref)] +fn validate_tools(tools: &HashMap, _: &()) -> garde::Result { + for (name, config) in tools { + config + .validate() + .map_err(|e| garde::Error::new(e.to_string()))?; - /// Convert to TOML string - pub fn to_toml_string(&self) -> Result { - toml::to_string_pretty(self).context("Failed to serialize ftl.toml") + // Ensure tool name follows naming conventions + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(garde::Error::new(format!( + "Tool name '{name}' contains invalid characters. Use only alphanumeric, dash, or underscore." + ))); + } } + Ok(()) +} - /// Get a list of all tool component names - pub fn tool_components(&self) -> Vec { - self.tools.keys().cloned().collect() +#[allow(clippy::trivially_copy_pass_by_ref)] +fn validate_access_control(value: &str, _: &()) -> garde::Result { + match value { + "public" | "private" => Ok(()), + _ => Err(garde::Error::new(format!( + "Invalid access_control '{value}'. Must be 'public' or 'private'." + ))), } } @@ -520,844 +398,67 @@ mod tests { use super::*; #[test] - fn test_parse_minimal_config() { - let content = r#" -[project] -name = "my-project" -"#; - - let config = FtlConfig::parse(content).unwrap(); - assert_eq!(config.project.name, "my-project"); - assert_eq!(config.project.version, "0.1.0"); - assert!(!config.auth.enabled); - } - - #[test] - fn test_validation_errors() { - // Test invalid project name - let content = r#" -[project] -name = "123-invalid" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Validation error")); - - // Test empty wasm path - let content = r#" -[project] -name = "valid-name" - -[tools.my-tool] -path = "my-tool" -wasm = "" - -[tools.my-tool.build] -command = "cargo build --target wasm32-wasip1 --release" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - - // Test auth validation - auth enabled but no provider configured - let content = r#" -[project] -name = "valid-name" - -[auth] -enabled = true -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains( - "Either 'authkit', 'oidc', or 'static_token' configuration must be provided" - )); - } - - #[test] - fn test_project_name_validation() { - // Valid project names - let valid_names = vec![ - "myproject", - "my-project", - "my_project", - "MyProject", - "project123", - "a", - "A", - ]; - - for name in valid_names { - let content = format!( - r#" -[project] -name = "{name}" -"# - ); - let result = FtlConfig::parse(&content); - assert!(result.is_ok(), "Project name '{name}' should be valid"); - } - - // Invalid project names - let invalid_names = vec![ - "", // empty - "123project", // starts with number - "-project", // starts with hyphen - "_project", // starts with underscore - "my project", // contains space - "my.project", // contains dot - "my@project", // contains special char - "my/project", // contains slash - ]; - - for name in invalid_names { - let content = format!( - r#" -[project] -name = "{name}" -"# - ); - let result = FtlConfig::parse(&content); - assert!(result.is_err(), "Project name '{name}' should be invalid"); - } - } - - #[test] - fn test_tool_name_validation() { - // Valid tool names - let valid_names = vec![ - "mytool", - "my-tool", - "my_tool", - "MyTool", - "tool123", - "tool-123_test", - ]; - - for name in valid_names { - let content = format!( - r#" -[project] -name = "test-project" - -[tools.{name}] -path = "tool-path" -wasm = "tool-path/output.wasm" - -[tools.{name}.build] -command = "cargo build --target wasm32-wasip1 --release" -"# - ); - let result = FtlConfig::parse(&content); - assert!(result.is_ok(), "Tool name '{name}' should be valid"); - } - - // Invalid tool names - let invalid_names = vec![ - "123tool", // starts with number - "-tool", // starts with hyphen - "_tool", // starts with underscore - "my tool", // contains space - "my.tool", // contains dot - "my@tool", // contains special char - "my/tool", // contains slash - "my$tool", // contains dollar sign - ]; - - for name in invalid_names { - let content = format!( - r#" -[project] -name = "test-project" - -[tools."{name}"] -path = "tool-path" -wasm = "tool-path/output.wasm" - -[tools."{name}".build] -command = "cargo build --target wasm32-wasip1 --release" -"# - ); - let result = FtlConfig::parse(&content); - assert!(result.is_err(), "Tool name '{name}' should be invalid"); - } - } - - #[test] - fn test_auth_validation() { - // Test auth disabled - should pass with no provider config - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = false -"#; - let result = FtlConfig::parse(content); - assert!(result.is_ok()); - - // Test auth enabled with authkit - should pass - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true - -[auth.authkit] -issuer = "https://my-tenant.authkit.app" -audience = "my-api" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.auth.provider_type(), "jwt"); - assert_eq!(config.auth.issuer(), "https://my-tenant.authkit.app"); - assert_eq!(config.auth.audience(), "my-api"); - - // Test auth enabled with oidc - should pass - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true - -[auth.oidc] -issuer = "https://auth.example.com" -audience = "api" -jwks_uri = "https://auth.example.com/.well-known/jwks.json" -authorize_endpoint = "https://auth.example.com/authorize" -token_endpoint = "https://auth.example.com/token" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.auth.provider_type(), "jwt"); - assert_eq!(config.auth.issuer(), "https://auth.example.com"); - - // Test auth enabled with no provider - should fail - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains( - "Either 'authkit', 'oidc', or 'static_token' configuration must be provided" - )); - - // Test auth enabled with both providers - should fail - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true - -[auth.authkit] -issuer = "https://my-tenant.authkit.app" -audience = "my-api" - -[auth.oidc] -issuer = "https://auth.example.com" -audience = "api" -jwks_uri = "https://auth.example.com/.well-known/jwks.json" -authorize_endpoint = "https://auth.example.com/authorize" -token_endpoint = "https://auth.example.com/token" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Only one of 'authkit', 'oidc', or 'static_token' can be configured") - ); - - // Test authkit with missing required field - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true - -[auth.authkit] -issuer = "" -audience = "my-api" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - } - - #[test] - fn test_oidc_config_validation() { - // Valid OIDC config - let content = r#" -[project] -name = "test-project" - -[auth] -enabled = true - -[auth.oidc] -issuer = "https://example.com" -audience = "my-api" -jwks_uri = "https://example.com/.well-known/jwks.json" -authorize_endpoint = "https://example.com/oauth/authorize" -token_endpoint = "https://example.com/oauth/token" -userinfo_endpoint = "https://example.com/oauth/userinfo" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_ok()); - let config = result.unwrap(); - assert!(config.auth.oidc.is_some()); - let oidc = config.auth.oidc.as_ref().unwrap(); - assert_eq!(oidc.jwks_uri, "https://example.com/.well-known/jwks.json"); - assert_eq!(oidc.userinfo_endpoint, "https://example.com/oauth/userinfo"); - } - - #[test] - fn test_empty_required_fields() { - // Test empty project name - let content = r#" -[project] -name = "" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - - // Test empty tool path - let content = r#" -[project] -name = "test-project" - -[tools.mytool] -type = "rust" -path = "" -"#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - } - - #[test] - fn test_default_values() { - let content = r#" -[project] -name = "test-project" -"#; - let config = FtlConfig::parse(content).unwrap(); - - // Check project defaults - assert_eq!(config.project.version, "0.1.0"); - assert_eq!(config.project.description, ""); - assert_eq!(config.project.authors.len(), 0); - - // Check auth defaults - assert!(!config.auth.enabled); - assert_eq!(config.auth.provider_type(), ""); - - // Check MCP defaults - assert_eq!(config.mcp.gateway, "ghcr.io/fastertools/mcp-gateway:0.0.10"); - assert_eq!( - config.mcp.authorizer, - "ghcr.io/fastertools/mcp-authorizer:0.0.10" - ); - assert!(config.mcp.validate_arguments); - } - - #[test] - fn test_parse_full_config() { - let content = r#" -[project] -name = "my-project" -version = "1.0.0" -description = "My FTL project" -authors = ["John Doe "] - -[auth] -enabled = true - -[auth.authkit] -issuer = "https://my-tenant.authkit.app" -audience = "mcp-api" - -[tools.echo] -path = "echo-rs" -wasm = "echo-rs/target/wasm32-wasip1/release/echo_rs.wasm" -allowed_outbound_hosts = [] - -[tools.echo.build] -command = "cargo build --target wasm32-wasip1 --release" -watch = ["src/**/*.rs", "Cargo.toml"] - -[tools.weather] -path = "weather-ts" -wasm = "weather-ts/dist/weather.wasm" -allowed_outbound_hosts = ["https://api.weather.com"] - -[tools.weather.build] -command = "npm run build:custom" -watch = ["src/**/*.ts", "package.json"] - -"#; - - let config = FtlConfig::parse(content).unwrap(); - assert_eq!(config.project.name, "my-project"); - assert_eq!(config.project.version, "1.0.0"); - assert!(config.auth.enabled); - assert_eq!(config.auth.provider_type(), "jwt"); - assert_eq!(config.tools.len(), 2); - assert_eq!( - config.tools["echo"].build.command, - "cargo build --target wasm32-wasip1 --release" - ); - assert_eq!( - config.tools["weather"].build.command, - "npm run build:custom" - ); - } - - #[test] - fn test_tool_components_method() { - let content = r#" + fn test_minimal_config() { + let config = r#" [project] name = "test-project" - -[tools.tool1] -path = "tool1" -wasm = "tool1/target/wasm32-wasip1/release/tool1.wasm" - -[tools.tool1.build] -command = "cargo build --target wasm32-wasip1 --release" - -[tools.tool2] -path = "tool2" -wasm = "tool2/dist/tool2.wasm" - -[tools.tool2.build] -command = "npm run build" - -[tools.tool3] -path = "tool3" -wasm = "tool3/dist/tool3.wasm" - -[tools.tool3.build] -command = "npm run build" "#; - let config = FtlConfig::parse(content).unwrap(); - let components = config.tool_components(); - assert_eq!(components.len(), 3); - assert!(components.contains(&"tool1".to_string())); - assert!(components.contains(&"tool2".to_string())); - assert!(components.contains(&"tool3".to_string())); - } - - #[test] - fn test_to_toml_string() { - let config = FtlConfig { - project: ProjectConfig { - name: "test-project".to_string(), - version: "1.0.0".to_string(), - description: "Test description".to_string(), - authors: vec!["Test Author ".to_string()], - }, - auth: AuthConfig::default(), - tools: HashMap::new(), - mcp: McpConfig::default(), - variables: HashMap::new(), - }; - - let toml_string = config.to_toml_string().unwrap(); - assert!(toml_string.contains("[project]")); - assert!(toml_string.contains("name = \"test-project\"")); - assert!(toml_string.contains("version = \"1.0.0\"")); - assert!(toml_string.contains("description = \"Test description\"")); - } - - #[test] - fn test_skip_empty_build_fields() { - let mut tools = HashMap::new(); - tools.insert( - "test-tool".to_string(), - ToolConfig { - path: Some("test-tool".to_string()), - wasm: "test-tool/target/wasm32-wasip1/release/test_tool.wasm".to_string(), - build: BuildConfig { - command: "cargo build --target wasm32-wasip1 --release".to_string(), - watch: vec!["src/**/*.rs".to_string()], - env: HashMap::new(), - }, - profiles: None, - up: None, - deploy: None, - allowed_outbound_hosts: vec![], - variables: HashMap::new(), - }, - ); - - let config = FtlConfig { - project: ProjectConfig { - name: "test-project".to_string(), - version: "0.1.0".to_string(), - description: String::new(), - authors: vec![], - }, - auth: AuthConfig::default(), - tools, - mcp: McpConfig::default(), - variables: HashMap::new(), - }; - - let toml_string = config.to_toml_string().unwrap(); - println!("Generated TOML:\n{toml_string}"); - - // Should NOT contain empty env section - assert!(!toml_string.contains("[tools.test-tool.build.env]")); - // Should contain watch array - assert!(toml_string.contains("watch = [")); + let ftl_config = FtlConfig::parse(config).unwrap(); + assert_eq!(ftl_config.project.name, "test-project"); + assert_eq!(ftl_config.project.version, "0.1.0"); + assert_eq!(ftl_config.project.access_control, "public"); + assert!(!ftl_config.is_auth_enabled()); } #[test] - fn test_application_variables() { - let content = r#" + fn test_private_without_oidc() { + let config = r#" [project] name = "test-project" - -[variables] -api_token = { required = true } -api_url = { default = "https://api.example.com" } -debug = { default = "false" } - -[tools.my-tool] -path = "my-tool" -wasm = "my-tool/target/wasm32-wasip1/release/my_tool.wasm" - -[tools.my-tool.build] -command = "cargo build --target wasm32-wasip1 --release" - -[tools.my-tool.variables] -token = "{{ api_token }}" -url = "{{ api_url }}" -debug_mode = "{{ debug }}" -static_var = "static-value" +access_control = "private" "#; - let config = FtlConfig::parse(content).unwrap(); - - // Check application variables - assert_eq!(config.variables.len(), 3); - - // Check required variable - if let Some(ApplicationVariable::Required { required }) = config.variables.get("api_token") - { - assert!(required); - } else { - panic!("api_token should be a required variable"); - } - - // Check default variables - if let Some(ApplicationVariable::Default { default }) = config.variables.get("api_url") { - assert_eq!(default, "https://api.example.com"); - } else { - panic!("api_url should have a default value"); - } - - // Check tool variables with templates - let tool = &config.tools["my-tool"]; - assert_eq!( - tool.variables.get("token"), - Some(&"{{ api_token }}".to_string()) - ); + let ftl_config = FtlConfig::parse(config).unwrap(); + assert!(ftl_config.is_auth_enabled()); assert_eq!( - tool.variables.get("url"), - Some(&"{{ api_url }}".to_string()) - ); - assert_eq!( - tool.variables.get("static_var"), - Some(&"static-value".to_string()) + ftl_config.auth_issuer(), + "https://divine-lion-50-staging.authkit.app" ); + assert_eq!(ftl_config.auth_provider_type(), "jwt"); } #[test] - fn test_from_file() { - use std::io::Write; - - let temp_dir = tempfile::tempdir().unwrap(); - let file_path = temp_dir.path().join("ftl.toml"); - - let content = r#" -[project] -name = "file-test-project" -version = "2.0.0" -"#; - - let mut file = std::fs::File::create(&file_path).unwrap(); - file.write_all(content.as_bytes()).unwrap(); - - let config = FtlConfig::from_file(&file_path).unwrap(); - assert_eq!(config.project.name, "file-test-project"); - assert_eq!(config.project.version, "2.0.0"); - } - - #[test] - fn test_custom_validation_edge_cases() { - // Test tool with environment variables - let content = r#" + fn test_private_with_oidc() { + let config = r#" [project] name = "test-project" +access_control = "private" -[tools.custom-build] -path = "custom" -wasm = "custom/dist/custom.wasm" - -[tools.custom-build.build] -command = "npm run build:special" -env = { NODE_ENV = "production", CUSTOM_VAR = "value" } -"#; - let config = FtlConfig::parse(content).unwrap(); - assert_eq!( - config.tools["custom-build"].build.command, - "npm run build:special" - ); - assert_eq!( - config.tools["custom-build"].build.env.get("NODE_ENV"), - Some(&"production".to_string()) - ); - - // Test tool with watch patterns - let content = r#" -[project] -name = "test-project" - -[tools.watch-tool] -path = "watch" -wasm = "watch/target/wasm32-wasip1/release/watch_tool.wasm" - -[tools.watch-tool.build] -command = "cargo build --target wasm32-wasip1 --release" -watch = ["**/*.rs", "Cargo.toml"] -"#; - let config = FtlConfig::parse(content).unwrap(); - assert_eq!(config.tools["watch-tool"].build.watch.len(), 2); - - // Test multiple validation errors at once - let content = r#" -[project] -name = "123invalid" - -[auth] -enabled = true -provider = "" - -[tools."bad@name"] -path = "" -wasm = "output.wasm" - -[tools."bad@name".build] -command = "" +[oidc] +issuer = "https://auth.example.com" +audience = "my-api" "#; - let result = FtlConfig::parse(content); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - // Should contain at least one validation error - assert!(error_msg.contains("Validation error")); + let ftl_config = FtlConfig::parse(config).unwrap(); + assert!(ftl_config.is_auth_enabled()); + assert_eq!(ftl_config.auth_issuer(), "https://auth.example.com"); + assert_eq!(ftl_config.auth_audience(), "my-api"); + assert_eq!(ftl_config.auth_provider_type(), "jwt"); } #[test] - fn test_build_configuration() { - // Test minimal build config - let content = r#" -[project] -name = "test-project" - -[tools.minimal] -path = "minimal-tool" -wasm = "minimal-tool/output.wasm" - -[tools.minimal.build] -command = "make build" -"#; - let config = FtlConfig::parse(content).unwrap(); - assert_eq!(config.tools["minimal"].build.command, "make build"); - assert!(config.tools["minimal"].build.watch.is_empty()); - assert!(config.tools["minimal"].build.env.is_empty()); - - // Test full build config - let content = r#" + fn test_invalid_access_control() { + let config = r#" [project] name = "test-project" - -[tools.full] -path = "full-tool" -wasm = "full-tool/target/wasm32-wasip1/release/full_tool.wasm" - -[tools.full.build] -command = "cargo build --release" -watch = ["src/**/*.rs", "Cargo.toml", "build.rs"] - -[tools.full.build.env] -RUSTFLAGS = "-C target-cpu=native" -CARGO_BUILD_JOBS = "4" -"#; - let config = FtlConfig::parse(content).unwrap(); - let build = &config.tools["full"].build; - assert_eq!(build.command, "cargo build --release"); - assert_eq!(build.watch.len(), 3); - assert_eq!(build.watch[2], "build.rs"); - assert_eq!( - build.env.get("RUSTFLAGS"), - Some(&"-C target-cpu=native".to_string()) - ); - assert_eq!(build.env.get("CARGO_BUILD_JOBS"), Some(&"4".to_string())); - - // Test missing build section validation - let content = r#" -[project] -name = "test-project" - -[tools.no-build] -path = "tool" +access_control = "custom" "#; - let result = FtlConfig::parse(content); + let result = FtlConfig::parse(config); assert!(result.is_err()); - // The error will be a TOML parse error about missing required field - } - - #[test] - fn test_build_profiles() { - // Test tool with multiple build profiles - let content = r#" -[project] -name = "test-project" - -[tools.myapp] -wasm = "myapp/target/wasm32-wasip1/release/myapp.wasm" - -[tools.myapp.build] -command = "cargo build --target wasm32-wasip1" - -[tools.myapp.profiles.dev] -command = "cargo build --target wasm32-wasip1" -watch = ["src/**/*.rs", "Cargo.toml"] -env = { RUST_LOG = "debug" } - -[tools.myapp.profiles.release] -command = "cargo build --target wasm32-wasip1 --release" -env = { RUST_LOG = "warn" } - -[tools.myapp.profiles.production] -command = "cargo build --target wasm32-wasip1 --release" -env = { RUST_LOG = "error", RUST_BACKTRACE = "1" } - -[tools.myapp.up] -profile = "dev" - -[tools.myapp.deploy] -profile = "production" -"#; - let config = FtlConfig::parse(content).unwrap(); - - let tool = &config.tools["myapp"]; - - // Check profiles exist - assert!(tool.profiles.is_some()); - let profiles = tool.profiles.as_ref().unwrap(); - assert_eq!(profiles.profiles.len(), 3); - - // Check dev profile - let dev = &profiles.profiles["dev"]; - assert_eq!(dev.command, "cargo build --target wasm32-wasip1"); - assert_eq!(dev.watch.len(), 2); - assert_eq!(dev.env.get("RUST_LOG"), Some(&"debug".to_string())); - - // Check release profile - let release = &profiles.profiles["release"]; - assert_eq!( - release.command, - "cargo build --target wasm32-wasip1 --release" - ); - assert_eq!(release.env.get("RUST_LOG"), Some(&"warn".to_string())); - - // Check production profile - let prod = &profiles.profiles["production"]; - assert_eq!(prod.env.get("RUST_BACKTRACE"), Some(&"1".to_string())); - - // Check up configuration - assert_eq!(tool.get_up_profile(), Some("dev")); - - // Check deploy configuration - assert_eq!(tool.deploy.as_ref().unwrap().profile, "production"); - - // Test getting build commands for different profiles - assert_eq!( - tool.get_build_command(Some("dev")), - "cargo build --target wasm32-wasip1" - ); - assert_eq!( - tool.get_build_command(Some("release")), - "cargo build --target wasm32-wasip1 --release" - ); - assert_eq!( - tool.get_build_command(None), - "cargo build --target wasm32-wasip1" - ); // default - } - - #[test] - fn test_deploy_configuration() { - // Test tool with deploy config - let content = r#" -[project] -name = "test-project" - -[tools.calc] -path = "calc" -wasm = "calc/target/wasm32-wasip1/release/calc.wasm" - -[tools.calc.build] -command = "cargo build --target wasm32-wasip1 --release" - -[tools.calc.deploy] -profile = "release" -name = "calculator" - -[tools.weather] -path = "weather" -wasm = "weather/dist/weather.wasm" - -[tools.weather.build] -command = "npm run build" - -[tools.weather.deploy] -profile = "production" -"#; - let config = FtlConfig::parse(content).unwrap(); - - // Check calc tool with custom name - assert_eq!( - config.tools["calc"].deploy.as_ref().unwrap().profile, - "release" - ); - assert_eq!( - config.tools["calc"].deploy.as_ref().unwrap().name, - Some("calculator".to_string()) - ); - - // Check weather tool without custom name - assert_eq!( - config.tools["weather"].deploy.as_ref().unwrap().profile, - "production" - ); + // The validation error message is in the general format assert!( - config.tools["weather"] - .deploy - .as_ref() - .unwrap() - .name - .is_none() + result + .unwrap_err() + .to_string() + .contains("validation failed") ); } } diff --git a/crates/commands/src/config/mod.rs b/crates/commands/src/config/mod.rs index 0b208bfd..3d2f5895 100644 --- a/crates/commands/src/config/mod.rs +++ b/crates/commands/src/config/mod.rs @@ -12,6 +12,6 @@ pub mod transpiler; /// Spin manifest configuration types pub mod spin_config; -pub use ftl_config::{AuthConfig, FtlConfig, ToolConfig}; +pub use ftl_config::{FtlConfig, ToolConfig}; pub use registry::{RegistryConfig, RegistryType}; pub use transpiler::transpile_ftl_to_spin; diff --git a/crates/commands/src/config/transpiler.rs b/crates/commands/src/config/transpiler.rs index 57477676..7049d42d 100644 --- a/crates/commands/src/config/transpiler.rs +++ b/crates/commands/src/config/transpiler.rs @@ -83,12 +83,27 @@ pub fn transpile_ftl_to_spin(ftl_config: &FtlConfig) -> Result { variables.insert( "auth_enabled".to_string(), SpinVariable::Default { - default: ftl_config.auth.enabled.to_string(), + default: ftl_config.is_auth_enabled().to_string(), }, ); // Only add other auth variables if auth is enabled - if ftl_config.auth.enabled { + if ftl_config.is_auth_enabled() { + // Add tenant_id variable for private mode without OIDC (platform will provide the value) + if ftl_config.project.access_control == "private" && ftl_config.oidc.is_none() { + variables.insert( + "mcp_tenant_id".to_string(), + SpinVariable::Required { required: true }, + ); + } else { + // For public mode or private with OIDC, tenant_id is empty + variables.insert( + "mcp_tenant_id".to_string(), + SpinVariable::Default { + default: String::new(), + }, + ); + } // Core MCP variables variables.insert( "mcp_gateway_url".to_string(), @@ -105,135 +120,114 @@ pub fn transpile_ftl_to_spin(ftl_config: &FtlConfig) -> Result { variables.insert( "mcp_provider_type".to_string(), SpinVariable::Default { - default: ftl_config.auth.provider_type().to_string(), + default: ftl_config.auth_provider_type().to_string(), + }, + ); + + // JWT provider variables (both FTL AuthKit and custom OIDC use JWT) + variables.insert( + "mcp_jwt_issuer".to_string(), + SpinVariable::Default { + default: ftl_config.auth_issuer().to_string(), + }, + ); + variables.insert( + "mcp_jwt_audience".to_string(), + SpinVariable::Default { + default: ftl_config.auth_audience().to_string(), + }, + ); + variables.insert( + "mcp_jwt_required_scopes".to_string(), + SpinVariable::Default { + default: ftl_config.auth_required_scopes().to_string(), }, ); - // JWT provider variables - if ftl_config.auth.authkit.is_some() || ftl_config.auth.oidc.is_some() { + // JWKS URI - empty for FTL AuthKit (auto-derived), explicit for OIDC + let jwks_uri = if let Some(oidc) = &ftl_config.oidc { + oidc.jwks_uri.clone() + } else { + String::new() + }; + variables.insert( + "mcp_jwt_jwks_uri".to_string(), + SpinVariable::Default { default: jwks_uri }, + ); + + // Public key and algorithm (OIDC only) + if let Some(oidc) = &ftl_config.oidc { variables.insert( - "mcp_jwt_issuer".to_string(), + "mcp_jwt_public_key".to_string(), SpinVariable::Default { - default: ftl_config.auth.issuer().to_string(), + default: oidc.public_key.clone(), }, ); variables.insert( - "mcp_jwt_audience".to_string(), + "mcp_jwt_algorithm".to_string(), SpinVariable::Default { - default: ftl_config.auth.audience().to_string(), + default: oidc.algorithm.clone(), }, ); + } else { variables.insert( - "mcp_jwt_required_scopes".to_string(), + "mcp_jwt_public_key".to_string(), SpinVariable::Default { - default: ftl_config.auth.required_scopes().to_string(), + default: String::new(), }, ); - - // JWKS URI - auto-derived for AuthKit, explicit for OIDC - let jwks_uri = if let Some(_authkit) = &ftl_config.auth.authkit { - // AuthKit auto-derives JWKS URI - String::new() - } else if let Some(oidc) = &ftl_config.auth.oidc { - oidc.jwks_uri.clone() - } else { - String::new() - }; variables.insert( - "mcp_jwt_jwks_uri".to_string(), - SpinVariable::Default { default: jwks_uri }, + "mcp_jwt_algorithm".to_string(), + SpinVariable::Default { + default: String::new(), + }, ); - - // Public key and algorithm (OIDC only) - if let Some(oidc) = &ftl_config.auth.oidc { - variables.insert( - "mcp_jwt_public_key".to_string(), - SpinVariable::Default { - default: oidc.public_key.clone(), - }, - ); - variables.insert( - "mcp_jwt_algorithm".to_string(), - SpinVariable::Default { - default: oidc.algorithm.clone(), - }, - ); - } else { - variables.insert( - "mcp_jwt_public_key".to_string(), - SpinVariable::Default { - default: String::new(), - }, - ); - variables.insert( - "mcp_jwt_algorithm".to_string(), - SpinVariable::Default { - default: String::new(), - }, - ); - } - - // OAuth discovery endpoints - if let Some(oidc) = &ftl_config.auth.oidc { - variables.insert( - "mcp_oauth_authorize_endpoint".to_string(), - SpinVariable::Default { - default: oidc.authorize_endpoint.clone(), - }, - ); - variables.insert( - "mcp_oauth_token_endpoint".to_string(), - SpinVariable::Default { - default: oidc.token_endpoint.clone(), - }, - ); - variables.insert( - "mcp_oauth_userinfo_endpoint".to_string(), - SpinVariable::Default { - default: oidc.userinfo_endpoint.clone(), - }, - ); - } else { - // Empty defaults for OAuth endpoints - let oauth_vars = [ - "mcp_oauth_authorize_endpoint", - "mcp_oauth_token_endpoint", - "mcp_oauth_userinfo_endpoint", - ]; - for var in &oauth_vars { - variables.insert( - (*var).to_string(), - SpinVariable::Default { - default: String::new(), - }, - ); - } - } } - // Static provider variables - if let Some(static_token) = &ftl_config.auth.static_token { + // OAuth discovery endpoints + if let Some(oidc) = &ftl_config.oidc { variables.insert( - "mcp_static_tokens".to_string(), + "mcp_oauth_authorize_endpoint".to_string(), SpinVariable::Default { - default: static_token.tokens.clone(), + default: oidc.authorize_endpoint.clone(), }, ); variables.insert( - "mcp_jwt_required_scopes".to_string(), + "mcp_oauth_token_endpoint".to_string(), SpinVariable::Default { - default: static_token.required_scopes.clone(), + default: oidc.token_endpoint.clone(), }, ); - } else if ftl_config.auth.static_token.is_none() { - // Empty default for static tokens if not using static provider variables.insert( - "mcp_static_tokens".to_string(), + "mcp_oauth_userinfo_endpoint".to_string(), SpinVariable::Default { - default: String::new(), + default: oidc.userinfo_endpoint.clone(), }, ); + } else { + // Empty defaults for OAuth endpoints + let oauth_vars = [ + "mcp_oauth_authorize_endpoint", + "mcp_oauth_token_endpoint", + "mcp_oauth_userinfo_endpoint", + ]; + for var in &oauth_vars { + variables.insert( + (*var).to_string(), + SpinVariable::Default { + default: String::new(), + }, + ); + } } + + // Static provider variables - no longer supported in new configuration + variables.insert( + "mcp_static_tokens".to_string(), + SpinVariable::Default { + default: String::new(), + }, + ); } spin_config.variables = variables; @@ -241,7 +235,7 @@ pub fn transpile_ftl_to_spin(ftl_config: &FtlConfig) -> Result { // Build components let mut components = HashMap::new(); - if ftl_config.auth.enabled { + if ftl_config.is_auth_enabled() { // When auth is enabled, add authorizer as "mcp" and gateway as "ftl-mcp-gateway" components.insert( "mcp".to_string(), @@ -275,7 +269,7 @@ pub fn transpile_ftl_to_spin(ftl_config: &FtlConfig) -> Result { redis: Vec::new(), }; - if ftl_config.auth.enabled { + if ftl_config.is_auth_enabled() { // When auth is enabled, all routes go through authorizer triggers.http.push(HttpTrigger { route: RouteConfig::Path("/...".to_string()), @@ -313,7 +307,13 @@ pub fn transpile_ftl_to_spin(ftl_config: &FtlConfig) -> Result { /// Create MCP authorizer component configuration fn create_mcp_component(registry_uri: &str) -> ComponentConfig { - let source = parse_registry_uri_to_source(registry_uri); + // Use default if empty + let uri = if registry_uri.is_empty() { + "ghcr.io/fastertools/mcp-authorizer:0.0.10" + } else { + registry_uri + }; + let source = parse_registry_uri_to_source(uri); let allowed_hosts = vec![ "http://*.spin.internal".to_string(), @@ -383,6 +383,12 @@ fn create_mcp_component(registry_uri: &str) -> ComponentConfig { "{{ mcp_static_tokens }}".to_string(), ); + // Tenant ID for private mode + variables.insert( + "mcp_tenant_id".to_string(), + "{{ mcp_tenant_id }}".to_string(), + ); + ComponentConfig { description: String::new(), source, @@ -400,7 +406,13 @@ fn create_mcp_component(registry_uri: &str) -> ComponentConfig { /// Create gateway component configuration fn create_gateway_component(registry_uri: &str, validate_args: bool) -> ComponentConfig { - let source = parse_registry_uri_to_source(registry_uri); + // Use default if empty + let uri = if registry_uri.is_empty() { + "ghcr.io/fastertools/mcp-gateway:0.0.10" + } else { + registry_uri + }; + let source = parse_registry_uri_to_source(uri); let allowed_hosts = vec!["http://*.spin.internal".to_string()]; diff --git a/crates/commands/src/config/transpiler_tests.rs b/crates/commands/src/config/transpiler_tests.rs index 621a8dba..29bc60eb 100644 --- a/crates/commands/src/config/transpiler_tests.rs +++ b/crates/commands/src/config/transpiler_tests.rs @@ -21,8 +21,9 @@ fn test_transpile_minimal_config() { version: "0.1.0".to_string(), description: "Test project".to_string(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -78,6 +79,11 @@ fn test_generate_temp_spin_toml_absolute_paths() { [project] name = "test-project" version = "0.1.0" +access_control = "public" + +[mcp] +gateway = "ghcr.io/fastertools/mcp-gateway:0.0.10" +authorizer = "ghcr.io/fastertools/mcp-authorizer:0.0.10" [tools.my-tool] path = "my-tool" @@ -176,8 +182,9 @@ fn test_transpile_with_tools() { version: "0.1.0".to_string(), description: String::new(), authors: vec!["Test Author ".to_string()], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: HashMap::new(), @@ -242,8 +249,9 @@ fn test_transpile_with_variables() { version: "0.1.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: HashMap::new(), @@ -273,17 +281,9 @@ fn test_transpile_with_auth() { version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "private".to_string(), }, - auth: AuthConfig { - enabled: true, - authkit: Some(AuthKitConfig { - issuer: "https://my-tenant.authkit.app".to_string(), - audience: "mcp-api".to_string(), - required_scopes: String::new(), - }), - oidc: None, - static_token: None, - }, + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -294,8 +294,15 @@ fn test_transpile_with_auth() { // Check auth configuration assert!(result.contains("auth_enabled = { default = \"true\" }")); assert!(result.contains("mcp_provider_type = { default = \"jwt\" }")); - assert!(result.contains("mcp_jwt_issuer = { default = \"https://my-tenant.authkit.app\" }")); - assert!(result.contains("mcp_jwt_audience = { default = \"mcp-api\" }")); + assert!( + result.contains( + "mcp_jwt_issuer = { default = \"https://divine-lion-50-staging.authkit.app\" }" + ) + ); + assert!(result.contains("mcp_jwt_audience = { default = \"\" }")); + + // For private mode without OIDC, tenant_id should be required + assert!(result.contains("mcp_tenant_id = { required = true }")); // Validate and check auth variables let spin_config = validate_spin_toml(&result).unwrap(); @@ -307,6 +314,10 @@ fn test_transpile_with_auth() { &spin_config.variables["mcp_provider_type"], SpinVariable::Default { default } if default == "jwt" )); + assert!(matches!( + &spin_config.variables["mcp_tenant_id"], + SpinVariable::Required { required: true } + )); } #[test] @@ -317,23 +328,19 @@ fn test_transpile_with_oidc_auth() { version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "private".to_string(), }, - auth: AuthConfig { - enabled: true, - authkit: None, - oidc: Some(OidcConfig { - issuer: "https://auth.example.com".to_string(), - audience: "api".to_string(), - jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(), - public_key: String::new(), - algorithm: String::new(), - required_scopes: String::new(), - authorize_endpoint: "https://auth.example.com/authorize".to_string(), - token_endpoint: "https://auth.example.com/token".to_string(), - userinfo_endpoint: "https://auth.example.com/userinfo".to_string(), - }), - static_token: None, - }, + oidc: Some(OidcConfig { + issuer: "https://auth.example.com".to_string(), + audience: "api".to_string(), + jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(), + public_key: String::new(), + algorithm: String::new(), + required_scopes: String::new(), + authorize_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + userinfo_endpoint: "https://auth.example.com/userinfo".to_string(), + }), tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -348,34 +355,34 @@ fn test_transpile_with_oidc_auth() { "mcp_jwt_jwks_uri = { default = \"https://auth.example.com/.well-known/jwks.json\" }" )); + // For private mode with OIDC, tenant_id should be empty (not required) + assert!(result.contains("mcp_tenant_id = { default = \"\" }")); + // Validate the generated TOML let spin_config = validate_spin_toml(&result).unwrap(); assert!(matches!( &spin_config.variables["mcp_provider_type"], SpinVariable::Default { default } if default == "jwt" )); + assert!(matches!( + &spin_config.variables["mcp_tenant_id"], + SpinVariable::Default { default } if default.is_empty() + )); } +// Static token auth is no longer supported in the new configuration +// This test is replaced with a test for public access control #[test] -fn test_transpile_with_static_token_auth() { +fn test_transpile_with_public_access() { let config = FtlConfig { project: ProjectConfig { - name: "static-auth-project".to_string(), + name: "public-project".to_string(), version: "0.1.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig { - enabled: true, - authkit: None, - oidc: None, - static_token: Some(StaticTokenConfig { - tokens: - "dev-token:client1:user1:read,write;admin-token:admin:admin:admin:1735689600" - .to_string(), - required_scopes: "read".to_string(), - }), - }, + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -383,25 +390,20 @@ fn test_transpile_with_static_token_auth() { let result = transpile_ftl_to_spin(&config).unwrap(); - println!("Generated TOML with static token auth:\n{result}"); + println!("Generated TOML with public access:\n{result}"); - // Check auth is enabled - assert!(result.contains("auth_enabled = { default = \"true\" }")); - - // Check static provider type - assert!(result.contains("mcp_provider_type = { default = \"static\" }")); - assert!(result.contains("mcp_static_tokens = { default = \"dev-token:client1:user1:read,write;admin-token:admin:admin:admin:1735689600\" }")); - assert!(result.contains("mcp_jwt_required_scopes = { default = \"read\" }")); + // Check auth is disabled for public access + assert!(result.contains("auth_enabled = { default = \"false\" }")); - // Check component names + // Check component names - no auth components assert!(result.contains("[component.mcp]")); - assert!(result.contains("[component.ftl-mcp-gateway]")); + assert!(!result.contains("[component.ftl-mcp-gateway]")); // Validate the generated TOML let spin_config = validate_spin_toml(&result).unwrap(); assert!(matches!( - &spin_config.variables["mcp_provider_type"], - SpinVariable::Default { default } if default == "static" + &spin_config.variables["auth_enabled"], + SpinVariable::Default { default } if default == "false" )); } @@ -413,8 +415,9 @@ fn test_transpile_with_custom_gateway_uris() { version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools: HashMap::new(), mcp: McpConfig { gateway: "ghcr.io/myorg/custom-gateway:2.0.0".to_string(), @@ -508,8 +511,9 @@ fn test_transpile_with_application_variables() { version: "0.1.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: app_vars, @@ -625,17 +629,9 @@ fn test_transpile_complete_example() { "John Doe ".to_string(), "Jane Smith ".to_string(), ], + access_control: "private".to_string(), }, - auth: AuthConfig { - enabled: true, - authkit: Some(AuthKitConfig { - issuer: "https://example.authkit.app".to_string(), - audience: "complete-example-api".to_string(), - required_scopes: String::new(), - }), - oidc: None, - static_token: None, - }, + oidc: None, tools, mcp: McpConfig { gateway: "ghcr.io/example/gateway:3.0.0".to_string(), @@ -661,7 +657,7 @@ fn test_transpile_complete_example() { ); assert_eq!(spin_config.application.authors.len(), 2); - // Check all components exist + // Check all components exist - private mode without OIDC has auth components assert!(spin_config.component.contains_key("mcp")); assert!(spin_config.component.contains_key("ftl-mcp-gateway")); assert!(spin_config.component.contains_key("database")); @@ -760,8 +756,9 @@ fn test_transpile_with_build_profiles() { version: "0.1.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: HashMap::new(), @@ -828,8 +825,9 @@ fn test_transpile_with_special_characters() { version: "0.1.0".to_string(), description: "Testing \"special\" characters & symbols".to_string(), authors: vec!["Author (Company & Co.)".to_string()], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: app_vars, @@ -861,8 +859,9 @@ fn test_transpile_empty_collections() { version: "0.1.0".to_string(), description: String::new(), // Empty description authors: vec![], // Empty authors + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools: HashMap::new(), // No tools mcp: McpConfig::default(), variables: HashMap::new(), // No variables @@ -984,8 +983,9 @@ fn test_http_trigger_generation() { version: "0.1.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig::default(), + oidc: None, tools, mcp: McpConfig::default(), variables: HashMap::new(), @@ -1014,20 +1014,16 @@ fn test_http_trigger_generation() { #[test] fn test_auth_disabled_omits_authorizer() { - // Test that when auth is disabled, the authorizer component is completely omitted + // Test that when auth is disabled (public access), the authorizer component is completely omitted let config = FtlConfig { project: ProjectConfig { name: "no-auth-project".to_string(), version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig { - enabled: false, - authkit: None, - oidc: None, - static_token: None, - }, + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -1078,24 +1074,16 @@ fn test_auth_disabled_omits_authorizer() { #[test] fn test_auth_enabled_includes_authorizer() { - // Test that when auth is enabled, all auth components are included + // Test that when auth is enabled (private access), all auth components are included let config = FtlConfig { project: ProjectConfig { name: "auth-project".to_string(), version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "private".to_string(), }, - auth: AuthConfig { - enabled: true, - authkit: Some(AuthKitConfig { - issuer: "https://example.authkit.app".to_string(), - audience: "test-api".to_string(), - required_scopes: String::new(), - }), - oidc: None, - static_token: None, - }, + oidc: None, tools: HashMap::new(), mcp: McpConfig::default(), variables: HashMap::new(), @@ -1110,6 +1098,7 @@ fn test_auth_enabled_includes_authorizer() { // Check that MCP authorizer component IS present assert!(result.contains("[component.mcp]")); + assert!(result.contains("[component.ftl-mcp-gateway]")); // Check that wildcard route is present assert!(result.contains("route = \"/...\"")); @@ -1155,7 +1144,7 @@ fn test_auth_enabled_includes_authorizer() { #[test] fn test_auth_disabled_with_tools() { - // Test that tools work correctly when auth is disabled + // Test that tools work correctly when auth is disabled (public access) let mut tools = HashMap::new(); tools.insert( "my-tool".to_string(), @@ -1181,13 +1170,9 @@ fn test_auth_disabled_with_tools() { version: "1.0.0".to_string(), description: String::new(), authors: vec![], + access_control: "public".to_string(), }, - auth: AuthConfig { - enabled: false, - authkit: None, - oidc: None, - static_token: None, - }, + oidc: None, tools, mcp: McpConfig::default(), variables: HashMap::new(), diff --git a/crates/commands/src/test_helpers.rs b/crates/commands/src/test_helpers.rs index f1f65d7c..309ffaee 100644 --- a/crates/commands/src/test_helpers.rs +++ b/crates/commands/src/test_helpers.rs @@ -1100,16 +1100,14 @@ pub fn test_ecr_credentials() -> types::CreateEcrTokenResponse { /// use ftl_commands::test_helpers::test_auth_config_response; /// /// let auth_response = test_auth_config_response(); -/// assert!(matches!(auth_response.mode, types::AuthConfigResponseMode::Public)); +/// assert!(matches!(auth_response.auth_config.access_control, types::AuthConfigResponseAuthConfigAccessControl::Public)); /// ``` pub fn test_auth_config_response() -> types::AuthConfigResponse { types::AuthConfigResponse { app_id: "app-12345".to_string(), auth_config: types::AuthConfigResponseAuthConfig { - mode: types::AuthConfigResponseAuthConfigMode::Public, + access_control: types::AuthConfigResponseAuthConfigAccessControl::Public, custom_config: None, - allowed_users: vec![], - allowed_tenants: vec![], }, updated_at: 1_234_567_890.0, } diff --git a/crates/runtime/openapi.json b/crates/runtime/openapi.json index ce4de911..dded40d5 100644 --- a/crates/runtime/openapi.json +++ b/crates/runtime/openapi.json @@ -863,9 +863,9 @@ "description": "Request body for updating authentication configuration", "type": "object", "properties": { - "mode": { + "accessControl": { "type": "string", - "enum": ["public", "ftl-account", "user-only", "custom"] + "enum": ["public", "private", "custom"] }, "customConfig": { "type": "object", @@ -887,21 +887,9 @@ } }, "required": ["provider", "issuer"] - }, - "allowedUsers": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowedTenants": { - "type": "array", - "items": { - "type": "string" - } } }, - "required": ["mode"] + "required": ["accessControl"] }, "ListAppsResponse": { "description": "List of applications with pagination", @@ -1192,9 +1180,9 @@ "authConfig": { "type": "object", "properties": { - "mode": { + "accessControl": { "type": "string", - "enum": ["public", "ftl-account", "user-only", "custom"] + "enum": ["public", "private", "custom"] }, "customConfig": { "type": "object", @@ -1217,21 +1205,9 @@ }, "required": ["provider", "issuer"], "additionalProperties": false - }, - "allowedUsers": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowedTenants": { - "type": "array", - "items": { - "type": "string" - } } }, - "required": ["mode"], + "required": ["accessControl"], "additionalProperties": false }, "updatedAt": { diff --git a/examples/demo/ftl.toml b/examples/demo/ftl.toml index bb81a99c..94aecea5 100644 --- a/examples/demo/ftl.toml +++ b/examples/demo/ftl.toml @@ -3,6 +3,10 @@ name = "ftl-mcp-demo" version = "0.1.0" description = "FTL MCP server for hosting MCP tools" authors = ["bowlofarugula "] +# Access control mode: "public" or "private" +# - public: No authentication required (default) +# - private: Authentication required +access_control = "public" [mcp] # You can specify registry URIs for custom MCP authorizer and gateway components: @@ -17,24 +21,13 @@ authorizer = "ghcr.io/fastertools/mcp-authorizer:0.0.10" validate_arguments = true # ======================================== -# Access control configuration +# OIDC Configuration (Optional) # ======================================== -# Access control is DISABLED by default. To enable, set auth_enabled = true +# For private access control with custom OIDC provider +# If omitted with private mode, uses FTL's built-in AuthKit -[auth] -enabled = false - - -# Uncomment and configure one of the following auth providers when enabling auth: - -# For AuthKit: -[auth.authkit] -issuer = "https://divine-lion-50-staging.authkit.app" -# audience = "mcp-api" # optional -# required_scopes = "read,write" # optional - -# For OIDC (Auth0, Okta, etc.): -# [auth.oidc] +# For custom OIDC (Auth0, Okta, etc.): +# [oidc] # issuer = "https://your-domain.auth0.com" # audience = "your-api-identifier" # optional # jwks_uri = "https://your-domain.auth0.com/.well-known/jwks.json" @@ -45,43 +38,23 @@ issuer = "https://divine-lion-50-staging.authkit.app" # token_endpoint = "https://your-domain.auth0.com/oauth/token" # optional - for OAuth discovery # userinfo_endpoint = "https://your-domain.auth0.com/userinfo" # optional - for OAuth discovery -# For Static Tokens (development/testing only): -# [auth.static_token] -# tokens = "dev-token:client1:user1:read,write;admin-token:admin:admin:admin:1735689600" -# required_scopes = "read" # optional - # ======================================== # Example configurations: # ======================================== -# AuthKit: -# [auth] -# enabled = true -# -# [auth.authkit] -# issuer = "https://your-tenant.authkit.app" -# audience = "mcp-api" # optional -# required_scopes = "mcp:read,mcp:write" # optional +# Private mode with FTL's built-in AuthKit (default for private): +# [project] +# access_control = "private" +# # No [oidc] block needed - FTL will use its built-in AuthKit # -# Auth0: -# [auth] -# enabled = true +# Private mode with custom OIDC: +# [project] +# access_control = "private" # -# [auth.oidc] +# [oidc] # issuer = "https://your-domain.auth0.com" -# audience = "your-api-identifier" # optional +# audience = "your-api-identifier" # jwks_uri = "https://your-domain.auth0.com/.well-known/jwks.json" -# required_scopes = "read,write" # optional -# authorize_endpoint = "https://your-domain.auth0.com/authorize" -# token_endpoint = "https://your-domain.auth0.com/oauth/token" -# userinfo_endpoint = "https://your-domain.auth0.com/userinfo" # optional -# -# Static Tokens (dev only): -# [auth] -# enabled = true -# -# [auth.static_token] -# tokens = "test-token:test-client:test-user:read,write" -# required_scopes = "read" +# required_scopes = "read,write" # ======================================== # Application Variables diff --git a/templates/ftl-mcp-server/content/ftl.toml b/templates/ftl-mcp-server/content/ftl.toml index 6b48e086..d4765648 100644 --- a/templates/ftl-mcp-server/content/ftl.toml +++ b/templates/ftl-mcp-server/content/ftl.toml @@ -3,25 +3,19 @@ name = "{{project-name | kebab_case}}" version = "0.1.0" description = "{{project-description}}" authors = ["{{authors}}"] +# Access control mode: "public" or "private" +# - public: No authentication required (default) +# - private: Authentication required +access_control = "public" # ======================================== -# Auth Gateway Configuration +# OIDC Configuration (Optional) # ======================================== -# Authentication is DISABLED by default. To enable, set auth_enabled = true +# For private access control with custom OIDC provider +# If omitted with private mode, uses FTL's built-in AuthKit -[auth] -enabled = false - -# Uncomment and configure one of the following auth providers when enabling auth: - -# For AuthKit: -# [auth.authkit] -# issuer = "https://your-tenant.authkit.app" -# audience = "mcp-api" # optional -# required_scopes = "mcp:read,mcp:write" # optional - -# For OIDC (Auth0, Okta, etc.): -# [auth.oidc] +# For custom OIDC (Auth0, Okta, etc.): +# [oidc] # issuer = "https://your-domain.auth0.com" # audience = "your-api-identifier" # optional # jwks_uri = "https://your-domain.auth0.com/.well-known/jwks.json" @@ -32,11 +26,6 @@ enabled = false # token_endpoint = "https://your-domain.auth0.com/oauth/token" # optional - for OAuth discovery # userinfo_endpoint = "https://your-domain.auth0.com/userinfo" # optional - for OAuth discovery -# For Static Tokens (development only): -# [auth.static_token] -# tokens = "dev-token:client1:user1:read,write" -# required_scopes = "read" # optional - [mcp] # MCP components with full registry URIs