diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs index 6036079b03..f064f6237f 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs @@ -44,12 +44,21 @@ impl VertexAuth { } }; - log::debug!("Attempting to auth using JsonString strategy"); + // Check if the string is too short to be valid JSON + if str.len() < 50 { + anyhow::bail!( + "Invalid GCP service account credentials: string is too short ({}). \ + Expected a JSON object with fields like 'private_key', 'client_email', etc. \ + Got: {}", + str.len(), + debug_str + ); + } + Self(Some(serde_json::from_str(str).context(format!("Failed to parse 'credentials' as GCP service account creds (are you using JSON format creds?); credentials={debug_str}"))?)) } ResolvedGcpAuthStrategy::JsonObject(json) => { // NB: this should never happen in WASM, there's no way to pass a JSON object in - log::debug!("Attempting to auth using JsonObject strategy"); Self(Some(serde_json::from_value( serde_json::to_value(json).context("Failed to parse service account credentials as GCP service account creds (issue during serialization)")?).context("Failed to parse service account credentials as GCP service account creds (are you using JSON format creds?)")?)) } @@ -144,9 +153,12 @@ impl ServiceAccount { async fn get_oauth2_token(&self) -> Result { let claims = Claims::from_service_account(self); - let jwt = encode_jwt(&serde_json::to_value(claims)?, &self.private_key) - .await - .map_err(|e| anyhow::anyhow!(format!("{e:?}")))?; + let jwt = encode_jwt( + &serde_json::to_value(claims).context("Failed to serialize claims as JSON")?, + &self.private_key, + ) + .await + .map_err(|e| anyhow::anyhow!(format!("JWT encoding error: {e:?}")))?; // Make the token request let client = reqwest::Client::new(); diff --git a/engine/baml-runtime/src/internal/wasm_jwt.rs b/engine/baml-runtime/src/internal/wasm_jwt.rs index 8793bc979c..c7f70087ed 100644 --- a/engine/baml-runtime/src/internal/wasm_jwt.rs +++ b/engine/baml-runtime/src/internal/wasm_jwt.rs @@ -23,16 +23,18 @@ use web_sys::{window, CryptoKey, SubtleCrypto}; #[derive(Error, Debug)] pub enum JwtError { - #[error("JavaScript error: {0:?}")] + #[error("Failed to import private key using WebCrypto API. This typically indicates:\n 1. The private key format is invalid (expected PKCS#8, got something else)\n 2. The private key data is corrupted or incomplete\n 3. The key contains embedded newline characters that need escaping in JSON\n\nTroubleshooting:\n - Verify your GCP service account JSON has a valid 'private_key' field\n - Ensure the private key starts with '-----BEGIN PRIVATE KEY-----' (not 'RSA PRIVATE KEY')\n - Check that newlines in the JSON are properly escaped as \\n\n Original error: {0:?}")] JsError(JsValue), - #[error("Base64 decode error: {0}")] + #[error("Failed to decode private key from base64. The private key in your service account credentials appears to be malformed. Error: {0}")] Base64Error(#[from] base64::DecodeError), - #[error("JSON error: {0}")] + #[error("Failed to serialize JWT claims as JSON: {0}")] JsonError(#[from] serde_json::Error), - #[error("Missing window object")] + #[error("WebCrypto API is not available (missing window object). This code must run in a browser or WASM environment.")] NoWindow, - #[error("Missing crypto API")] + #[error("WebCrypto API is not available (missing crypto API). Your browser may not support the required cryptographic operations.")] NoCrypto, + #[error("Private key is too short ({0} bytes). Expected at least 100 bytes for a valid RSA private key. This likely indicates the key is invalid, corrupted, or just test data.")] + KeyTooShort(usize), } impl From for JwtError { @@ -62,12 +64,24 @@ pub async fn encode_jwt( let signing_input = format!("{header_segment}.{claims_segment}"); // Convert PEM to importable key format + // Remove PEM headers/footers and whitespace (newlines, carriage returns, tabs) + // Note: Do NOT remove spaces as they may be part of valid base64 content let pem = private_key_pem .trim() .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") - .replace('\n', ""); - let key_data = STANDARD.decode(pem)?; + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + // Handle both literal \n strings (from JSON) and actual newline characters + .replace("\\n", "") + .replace(['\n', '\r', '\t'], ""); + + let key_data = STANDARD.decode(&pem)?; + + // Validate key length before attempting to import + if key_data.len() < 100 { + return Err(JwtError::KeyTooShort(key_data.len())); + } // Import the key let import_params = Object::new(); diff --git a/typescript/packages/playground-common/src/components/api-keys-dialog/atoms.ts b/typescript/packages/playground-common/src/components/api-keys-dialog/atoms.ts index 234989f5ff..81702da8c1 100644 --- a/typescript/packages/playground-common/src/components/api-keys-dialog/atoms.ts +++ b/typescript/packages/playground-common/src/components/api-keys-dialog/atoms.ts @@ -78,10 +78,10 @@ export const envKeyValuesAtom = atom( | { itemIndex: number; remove: true } // Insert key | { - itemIndex: null; - key: string; - value?: string; - }, + itemIndex: null; + key: string; + value?: string; + }, ) => { if (update.itemIndex !== null) { const keyValues = [...get(envKeyValueStorage)]; @@ -365,13 +365,16 @@ export const deleteApiKeyAtom = atom( delete newVars[key]; return newVars; }); - set(hasLocalChangesAtom, true); // Remove from recently added keys if it was there set(recentlyAddedKeysAtom, (prev: Set) => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); + // Auto-save the deletion immediately + const localApiKeys = get(localApiKeysAtom); + set(userApiKeysAtom, localApiKeys); + set(hasLocalChangesAtom, false); } );