diff --git a/packages/drivers/src/snowflake.ts b/packages/drivers/src/snowflake.ts index aa9b381bff..240beb55aa 100644 --- a/packages/drivers/src/snowflake.ts +++ b/packages/drivers/src/snowflake.ts @@ -48,43 +48,165 @@ export async function connect(config: ConnectionConfig): Promise { role: config.role, } - // Key-pair auth - if (config.private_key_path) { - const keyPath = config.private_key_path as string - if (!fs.existsSync(keyPath)) { - throw new Error(`Snowflake private key file not found: ${keyPath}`) + // --------------------------------------------------------------- + // Normalize field names: accept snake_case (dbt), camelCase (SDK), + // and common LLM-generated variants so auth "just works". + // --------------------------------------------------------------- + const keyPath = (config.private_key_path ?? config.privateKeyPath) as string | undefined + const inlineKey = (config.private_key ?? config.privateKey) as string | undefined + const keyPassphrase = (config.private_key_passphrase ?? config.privateKeyPassphrase ?? config.privateKeyPass) as string | undefined + const oauthToken = (config.token ?? config.access_token) as string | undefined + const oauthClientId = (config.oauth_client_id ?? config.oauthClientId) as string | undefined + const oauthClientSecret = (config.oauth_client_secret ?? config.oauthClientSecret) as string | undefined + const authenticator = (config.authenticator as string | undefined)?.trim() + const authUpper = authenticator?.toUpperCase() + const passcode = config.passcode as string | undefined + + // --------------------------------------------------------------- + // 1. Key-pair auth (SNOWFLAKE_JWT) + // Accepts: private_key_path (file), private_key (inline PEM or + // file path auto-detected), privateKey, privateKeyPath. + // --------------------------------------------------------------- + // Resolve private_key: could be a file path or PEM content + let resolvedKeyPath = keyPath + let resolvedInlineKey = inlineKey + if (!resolvedKeyPath && resolvedInlineKey && !resolvedInlineKey.includes("-----BEGIN")) { + // Looks like a file path, not PEM content + if (fs.existsSync(resolvedInlineKey)) { + resolvedKeyPath = resolvedInlineKey + resolvedInlineKey = undefined + } else { + throw new Error( + `Snowflake private key: '${resolvedInlineKey}' is not a valid file path or PEM content. ` + + `Use 'private_key_path' for file paths or provide PEM content starting with '-----BEGIN PRIVATE KEY-----'.`, + ) + } + } + + if (resolvedKeyPath || resolvedInlineKey) { + let keyContent: string + if (resolvedKeyPath) { + if (!fs.existsSync(resolvedKeyPath)) { + throw new Error(`Snowflake private key file not found: ${resolvedKeyPath}`) + } + keyContent = fs.readFileSync(resolvedKeyPath, "utf-8") + } else { + keyContent = resolvedInlineKey! + // Normalize escaped newlines from env vars / JSON configs + if (keyContent.includes("\\n")) { + keyContent = keyContent.replace(/\\n/g, "\n") + } } - const keyContent = fs.readFileSync(keyPath, "utf-8") - // If key is encrypted (has ENCRYPTED in header or passphrase provided), - // decrypt it using Node crypto — snowflake-sdk expects unencrypted PEM. + // If key is encrypted, decrypt using Node crypto — + // snowflake-sdk expects unencrypted PKCS#8 PEM. let privateKey: string - if (config.private_key_passphrase || keyContent.includes("ENCRYPTED")) { + if (keyPassphrase || keyContent.includes("ENCRYPTED")) { const crypto = await import("crypto") - const keyObject = crypto.createPrivateKey({ - key: keyContent, - format: "pem", - passphrase: (config.private_key_passphrase as string) || undefined, - }) - privateKey = keyObject - .export({ type: "pkcs8", format: "pem" }) - .toString() + try { + const keyObject = crypto.createPrivateKey({ + key: keyContent, + format: "pem", + passphrase: keyPassphrase || undefined, + }) + privateKey = keyObject + .export({ type: "pkcs8", format: "pem" }) + .toString() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + throw new Error( + `Snowflake: Failed to decrypt private key. Verify the passphrase and key format (must be PEM/PKCS#8). ${msg}`, + ) + } } else { privateKey = keyContent } options.authenticator = "SNOWFLAKE_JWT" options.privateKey = privateKey + + // --------------------------------------------------------------- + // 2. External browser SSO + // Interactive — opens user's browser for IdP login. Requires + // connectAsync() instead of connect(). + // --------------------------------------------------------------- + } else if (authUpper === "EXTERNALBROWSER") { + options.authenticator = "EXTERNALBROWSER" + + // --------------------------------------------------------------- + // 3. Okta native SSO (authenticator is an Okta URL) + // --------------------------------------------------------------- + } else if (authenticator && /^https?:\/\/.+\.okta\.com/i.test(authenticator)) { + options.authenticator = authenticator + if (config.password) options.password = config.password + + // --------------------------------------------------------------- + // 4. OAuth token auth + // Triggered by: authenticator="oauth", OR token/access_token + // present without a password. + // --------------------------------------------------------------- + } else if (authUpper === "OAUTH" || (oauthToken && !config.password)) { + if (!oauthToken) { + throw new Error( + "Snowflake OAuth authenticator specified but no token provided (expected 'token' or 'access_token')", + ) + } + options.authenticator = "OAUTH" + options.token = oauthToken + + // --------------------------------------------------------------- + // 5. JWT / Programmatic access token (pre-generated) + // The Node.js snowflake-sdk only accepts pre-generated tokens + // via the OAUTH authenticator. SNOWFLAKE_JWT expects a privateKey + // for self-signing, and PROGRAMMATIC_ACCESS_TOKEN is not recognized. + // Alias both to OAUTH so the token is passed correctly. + // --------------------------------------------------------------- + } else if (authUpper === "JWT" || authUpper === "PROGRAMMATIC_ACCESS_TOKEN") { + if (!oauthToken) { + throw new Error(`Snowflake ${authenticator} authenticator specified but no token provided (expected 'token' or 'access_token')`) + } + options.authenticator = "OAUTH" + options.token = oauthToken + + // --------------------------------------------------------------- + // 7. Username + password + MFA + // --------------------------------------------------------------- + } else if (authUpper === "USERNAME_PASSWORD_MFA") { + if (!config.password) { + throw new Error("Snowflake USERNAME_PASSWORD_MFA authenticator requires 'password'") + } + options.authenticator = "USERNAME_PASSWORD_MFA" + options.password = config.password + if (passcode) options.passcode = passcode + + // --------------------------------------------------------------- + // 8. Plain password auth (default) + // --------------------------------------------------------------- } else if (config.password) { options.password = config.password } + // Use connectAsync for browser-based auth (SSO/Okta), connect for everything else + const isOktaUrl = authenticator && /^https?:\/\/.+\.okta\.com/i.test(authenticator) + const useBrowserAuth = authUpper === "EXTERNALBROWSER" || isOktaUrl + connection = await new Promise((resolve, reject) => { const conn = snowflake.createConnection(options) - conn.connect((err: Error | null) => { - if (err) reject(err) - else resolve(conn) - }) + if (useBrowserAuth) { + if (typeof conn.connectAsync !== "function") { + reject(new Error("Snowflake browser/SSO auth requires snowflake-sdk with connectAsync support. Upgrade snowflake-sdk.")) + return + } + conn.connectAsync((err: Error | null) => { + if (err) reject(err) + else resolve(conn) + }).catch(reject) + } else { + conn.connect((err: Error | null) => { + if (err) reject(err) + else resolve(conn) + }) + } }) }, diff --git a/packages/opencode/src/altimate/native/connections/credential-store.ts b/packages/opencode/src/altimate/native/connections/credential-store.ts index 724455860e..10be89e480 100644 --- a/packages/opencode/src/altimate/native/connections/credential-store.ts +++ b/packages/opencode/src/altimate/native/connections/credential-store.ts @@ -14,8 +14,16 @@ const SERVICE_NAME = "altimate-code" const SENSITIVE_FIELDS = new Set([ "password", + "private_key", + "privateKey", "private_key_passphrase", + "privateKeyPassphrase", + "privateKeyPass", "access_token", + "token", + "oauth_client_secret", + "oauthClientSecret", + "passcode", "ssh_password", "connection_string", ]) diff --git a/packages/opencode/src/altimate/native/connections/dbt-profiles.ts b/packages/opencode/src/altimate/native/connections/dbt-profiles.ts index a384729b7a..e08892e128 100644 --- a/packages/opencode/src/altimate/native/connections/dbt-profiles.ts +++ b/packages/opencode/src/altimate/native/connections/dbt-profiles.ts @@ -36,8 +36,12 @@ const KEY_MAP: Record = { server_hostname: "server_hostname", http_path: "http_path", token: "access_token", + private_key: "private_key", private_key_path: "private_key_path", private_key_passphrase: "private_key_passphrase", + authenticator: "authenticator", + oauth_client_id: "oauth_client_id", + oauth_client_secret: "oauth_client_secret", keyfile: "credentials_path", keyfile_json: "credentials_json", project: "project", diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index c0fbf6b522..7af182e956 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -209,7 +209,10 @@ async function createConnector( export function detectAuthMethod(config: ConnectionConfig | null | undefined): string { if (!config || typeof config !== "object") return "unknown" if (config.connection_string) return "connection_string" - if (config.private_key_path) return "key_pair" + if (config.private_key_path || config.privateKeyPath || config.private_key || config.privateKey) return "key_pair" + const auth = typeof config.authenticator === "string" ? config.authenticator.toUpperCase() : "" + if (auth === "EXTERNALBROWSER" || (typeof config.authenticator === "string" && /^https?:\/\/.+\.okta\.com/i.test(config.authenticator))) return "sso" + if (auth === "OAUTH") return "oauth" if (config.access_token || config.token) return "token" if (config.password) return "password" const t = typeof config.type === "string" ? config.type.toLowerCase() : "" @@ -374,8 +377,10 @@ export async function add( existing[name] = sanitized fs.writeFileSync(globalPath, JSON.stringify(existing, null, 2), "utf-8") - // Update in-memory with sanitized config (no plaintext credentials) - configs.set(name, sanitized) + // In-memory: keep original config (with credentials) so the current + // session can connect even when keytar is unavailable. Only the disk + // file uses the sanitized version (credentials stripped). + configs.set(name, config) // Clear cached connector const cached = connectors.get(name) diff --git a/packages/opencode/src/altimate/tools/dbt-profiles.ts b/packages/opencode/src/altimate/tools/dbt-profiles.ts index 2642880bc0..1573ad26bc 100644 --- a/packages/opencode/src/altimate/tools/dbt-profiles.ts +++ b/packages/opencode/src/altimate/tools/dbt-profiles.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "../../tool/tool" import { Dispatcher } from "../native" +import { isSensitiveField } from "../native/connections/credential-store" export const DbtProfilesTool = Tool.define("dbt_profiles", { description: @@ -52,7 +53,7 @@ function formatConnections(connections: Array<{ name: string; type: string; conf for (const conn of connections) { lines.push(`${conn.name} (${conn.type})`) for (const [key, val] of Object.entries(conn.config)) { - if (key === "password" || key === "private_key_passphrase" || key === "access_token") { + if (isSensitiveField(key)) { lines.push(` ${key}: ****`) } else { lines.push(` ${key}: ${val}`) diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index d396715387..7127027215 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -10,7 +10,15 @@ export const WarehouseAddTool = Tool.define("warehouse_add", { config: z .record(z.string(), z.unknown()) .describe( - 'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). Example: {"type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "admin", "password": "secret"}', + 'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). ' + + 'Snowflake auth methods: ' + + '(1) Password: {"type":"snowflake","account":"xy12345","user":"admin","password":"secret","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + + '(2) Key-pair (file): {"type":"snowflake","account":"xy12345","user":"admin","private_key_path":"/path/to/rsa_key.p8","private_key_passphrase":"optional","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + + '(3) Key-pair (inline): use "private_key" instead of "private_key_path" with PEM content. ' + + '(4) OAuth: {"type":"snowflake","account":"xy12345","authenticator":"oauth","token":"","warehouse":"WH","database":"db","schema":"public"}. ' + + '(5) SSO: {"type":"snowflake","account":"xy12345","user":"admin","authenticator":"externalbrowser","warehouse":"WH","database":"db","schema":"public","role":"ROLE"}. ' + + 'IMPORTANT: For private key file paths, always use "private_key_path" (not "private_key"). ' + + 'Postgres: {"type":"postgres","host":"localhost","port":5432,"database":"mydb","user":"admin","password":"secret"}.', ), }), async execute(args, ctx) { diff --git a/packages/opencode/test/altimate/connections.test.ts b/packages/opencode/test/altimate/connections.test.ts index f81378784c..9558a79565 100644 --- a/packages/opencode/test/altimate/connections.test.ts +++ b/packages/opencode/test/altimate/connections.test.ts @@ -105,10 +105,35 @@ describe("CredentialStore", () => { test("isSensitiveField identifies sensitive fields", () => { expect(CredentialStore.isSensitiveField("password")).toBe(true) + expect(CredentialStore.isSensitiveField("private_key")).toBe(true) + expect(CredentialStore.isSensitiveField("privateKey")).toBe(true) + expect(CredentialStore.isSensitiveField("private_key_passphrase")).toBe(true) + expect(CredentialStore.isSensitiveField("privateKeyPassphrase")).toBe(true) + expect(CredentialStore.isSensitiveField("privateKeyPass")).toBe(true) expect(CredentialStore.isSensitiveField("access_token")).toBe(true) + expect(CredentialStore.isSensitiveField("token")).toBe(true) + expect(CredentialStore.isSensitiveField("oauth_client_secret")).toBe(true) + expect(CredentialStore.isSensitiveField("oauthClientSecret")).toBe(true) + expect(CredentialStore.isSensitiveField("passcode")).toBe(true) expect(CredentialStore.isSensitiveField("connection_string")).toBe(true) expect(CredentialStore.isSensitiveField("host")).toBe(false) expect(CredentialStore.isSensitiveField("port")).toBe(false) + expect(CredentialStore.isSensitiveField("authenticator")).toBe(false) + }) + + test("saveConnection strips inline private_key as sensitive", async () => { + const config = { type: "snowflake", private_key: "-----BEGIN PRIVATE KEY-----\nMIIE..." } as any + const { sanitized, warnings } = await CredentialStore.saveConnection("sf_keypair", config) + expect(sanitized.private_key).toBeUndefined() + expect(warnings.length).toBeGreaterThan(0) + }) + + test("saveConnection strips OAuth credentials as sensitive", async () => { + const config = { type: "snowflake", authenticator: "oauth", token: "access-token-123", oauth_client_secret: "secret" } as any + const { sanitized } = await CredentialStore.saveConnection("sf_oauth", config) + expect(sanitized.token).toBeUndefined() + expect(sanitized.oauth_client_secret).toBeUndefined() + expect(sanitized.authenticator).toBe("oauth") }) }) @@ -165,6 +190,44 @@ myproject: } }) + test("parses Snowflake private_key from dbt profile", async () => { + const fs = await import("fs") + const os = await import("os") + const path = await import("path") + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbt-test-")) + const profilesPath = path.join(tmpDir, "profiles.yml") + + fs.writeFileSync( + profilesPath, + ` +snowflake_keypair: + outputs: + prod: + type: snowflake + account: abc123 + user: svc_user + private_key: "-----BEGIN PRIVATE KEY-----\\nMIIEvQ..." + private_key_passphrase: "my-passphrase" + database: ANALYTICS + warehouse: COMPUTE_WH + schema: PUBLIC + role: TRANSFORMER +`, + ) + + try { + const connections = await parseDbtProfiles(profilesPath) + expect(connections).toHaveLength(1) + expect(connections[0].type).toBe("snowflake") + expect(connections[0].config.private_key).toBe("-----BEGIN PRIVATE KEY-----\nMIIEvQ...") + expect(connections[0].config.private_key_passphrase).toBe("my-passphrase") + expect(connections[0].config.password).toBeUndefined() + } finally { + fs.rmSync(tmpDir, { recursive: true }) + } + }) + test("maps dbt adapter types correctly", async () => { const fs = await import("fs") const os = await import("os") diff --git a/packages/opencode/test/altimate/telemetry-safety.test.ts b/packages/opencode/test/altimate/telemetry-safety.test.ts index 10bf2ee1e6..c268047ed0 100644 --- a/packages/opencode/test/altimate/telemetry-safety.test.ts +++ b/packages/opencode/test/altimate/telemetry-safety.test.ts @@ -46,7 +46,15 @@ describe("Telemetry Safety: Helper functions never throw", () => { test("detectAuthMethod handles all config shapes", () => { expect(detectAuthMethod({ type: "postgres", connection_string: "pg://..." })).toBe("connection_string") expect(detectAuthMethod({ type: "snowflake", private_key_path: "/key.p8" })).toBe("key_pair") + expect(detectAuthMethod({ type: "snowflake", private_key: "-----BEGIN PRIVATE KEY-----\n..." })).toBe("key_pair") + expect(detectAuthMethod({ type: "snowflake", privateKey: "-----BEGIN PRIVATE KEY-----\n..." })).toBe("key_pair") + expect(detectAuthMethod({ type: "snowflake", privateKeyPath: "/key.p8" })).toBe("key_pair") + expect(detectAuthMethod({ type: "snowflake", authenticator: "externalbrowser" })).toBe("sso") + expect(detectAuthMethod({ type: "snowflake", authenticator: "https://myorg.okta.com" })).toBe("sso") + expect(detectAuthMethod({ type: "snowflake", authenticator: "oauth" })).toBe("oauth") + expect(detectAuthMethod({ type: "snowflake", authenticator: "OAUTH" })).toBe("oauth") expect(detectAuthMethod({ type: "databricks", access_token: "dapi..." })).toBe("token") + expect(detectAuthMethod({ type: "snowflake", token: "jwt-token" })).toBe("token") expect(detectAuthMethod({ type: "postgres", password: "secret" })).toBe("password") expect(detectAuthMethod({ type: "duckdb" })).toBe("file") expect(detectAuthMethod({ type: "sqlite" })).toBe("file")