From 9c4d440b903cfaaa8fbd704f7688aa36fd543877 Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Fri, 7 Aug 2026 18:50:08 +0200 Subject: [PATCH] feat(core): add Snowflake Cortex OAuth login for V2 Port account-scoped PKCE OAuth from the V1 plugin into the V2 snowflake-cortex provider: browser login with account/role prompts, refresh via credential metadata, and request-time Cortex baseURL resolution from OAuth accountId/baseURL so native openai-compatible routes hit the correct host with a bearer access token. --- .../src/plugin/provider/snowflake-cortex.ts | 294 +++++++++++++++++- packages/core/src/session/runner/model.ts | 27 +- .../plugin/provider-snowflake-cortex.test.ts | 62 +++- .../core/test/session-runner-model.test.ts | 61 ++++ 4 files changed, 438 insertions(+), 6 deletions(-) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 788ac63eb037..ae7f387b3052 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,9 +1,35 @@ -import { Effect } from "effect" -import { define } from "../internal" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Deferred, Effect } from "effect" +import type { Scope } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +const OAUTH_CLIENT_ID = "LOCAL_APPLICATION" +const OAUTH_CALLBACK_HOST = "127.0.0.1" +const OAUTH_CALLBACK_PATH = "/" +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000 +const methodID = Integration.MethodID.make("snowflake-browser") type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + access_token: string + refresh_token?: string + expires_in?: number + token_type?: string +} + // Exported for testing: intercepts Cortex-specific request/response quirks. export function cortexFetch(upstream: FetchLike = fetch) { return async (url: string | URL | Request, init?: RequestInit): Promise => { @@ -64,9 +90,271 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } +export function normalizeAccount(input: string) { + return input + .trim() + .replace(/^https?:\/\//, "") + .replace(/\.snowflakecomputing\.com\/?$/, "") + .replace(/\/+$/, "") +} + +export function oauthScope(role: string | undefined) { + if (!role) return "refresh_token" + return /^[-_A-Za-z0-9]+$/.test(role) + ? `refresh_token session:role:${role}` + : `refresh_token session:role-encoded:${encodeURIComponent(role)}` +} + +export function cortexBaseURL(accountId: string) { + return `https://${accountId}.snowflakecomputing.com/api/v2/cortex/v1` +} + +function authHeaders() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function authBasicHeader() { + return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}` +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(64)), (byte) => chars[byte % chars.length]).join( + "", + ) + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`Request failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function exchange(account: string, code: string, redirect: string, pkce: Pkce) { + return request(`https://${account}.snowflakecomputing.com/oauth/token-request`, { + method: "POST", + headers: { + ...authHeaders(), + Authorization: authBasicHeader(), + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: OAUTH_CLIENT_ID, + code_verifier: pkce.verifier, + }).toString(), + }).pipe( + Effect.flatMap((token) => { + if (!token.access_token) return Effect.fail(new Error("Snowflake token response did not include access_token")) + if (!token.refresh_token) { + return Effect.fail( + new Error( + "Snowflake token response did not include refresh_token. Ensure the integration issues refresh tokens and scope includes refresh_token.", + ), + ) + } + return Effect.succeed(token) + }), + ) +} + +function refresh(value: Pick) { + const accountId = typeof value.metadata?.accountId === "string" ? value.metadata.accountId : undefined + if (!accountId) return Effect.fail(new Error("Snowflake OAuth credential is missing accountId metadata")) + return request(`https://${accountId}.snowflakecomputing.com/oauth/token-request`, { + method: "POST", + headers: { + ...authHeaders(), + Authorization: authBasicHeader(), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: OAUTH_CLIENT_ID, + }).toString(), + }).pipe( + Effect.flatMap((token) => { + if (!token.access_token) return Effect.fail(new Error("Snowflake refresh response did not include access_token")) + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + access: token.access_token, + refresh: token.refresh_token || value.refresh, + expires: Date.now() + (token.expires_in ?? 600) * 1000, + metadata: { accountId, baseURL: cortexBaseURL(accountId) }, + }), + ) + }), + ) +} + +function credential(accountId: string, tokens: TokenResponse) { + return Credential.OAuth.make({ + type: "oauth", + methodID, + access: tokens.access_token, + refresh: tokens.refresh_token!, + expires: Date.now() + (tokens.expires_in ?? 600) * 1000, + metadata: { accountId, baseURL: cortexBaseURL(accountId) }, + }) +} + +const browser = { + integrationID: "snowflake-cortex", + method: { + id: methodID, + type: "oauth", + label: "Login with Snowflake (External Browser)", + prompts: [ + { + type: "text" as const, + key: "account", + message: "Snowflake Account Identifier", + placeholder: "myorg-myaccount", + }, + { + type: "text" as const, + key: "role", + message: "Snowflake Role (optional)", + placeholder: "PUBLIC", + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const account = normalizeAccount(inputs.account || "") + if (!account) return yield* Effect.fail(new Error("Snowflake account is required")) + const role = (inputs.role || "").trim() || undefined + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + + const server = createServer((request, response) => { + const host = request.headers.host || `${OAUTH_CALLBACK_HOST}:0` + const url = new URL(request.url ?? "/", `http://${host}`) + if (url.pathname !== OAUTH_CALLBACK_PATH) { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "Snowflake" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "Snowflake" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response + .writeHead(200, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.success({ provider: "Snowflake" })) + }) + + const redirect = yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(0, OAUTH_CALLBACK_HOST, () => { + const address = server.address() + if (!address || typeof address === "string") { + resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port"))) + return + } + resume(Effect.succeed(`http://${OAUTH_CALLBACK_HOST}:${address.port}${OAUTH_CALLBACK_PATH}`)) + }) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + + const scope = oauthScope(role) + const authorizeURL = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({ + client_id: OAUTH_CLIENT_ID, + response_type: "code", + redirect_uri: redirect, + scope, + state, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + }).toString()}` + + return { + mode: "auto" as const, + url: authorizeURL, + instructions: + "Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.", + callback: Effect.raceFirst( + Deferred.await(code), + Effect.sleep(OAUTH_TIMEOUT_MS).pipe( + Effect.flatMap(() => + Effect.fail(new Error("Snowflake OAuth callback timeout - authorization took too long")), + ), + ), + ).pipe( + Effect.flatMap((value) => exchange(account, value, redirect, pkce)), + Effect.map((tokens) => credential(account, tokens)), + ), + } + }), + refresh: (value) => refresh(value), + label: (credential) => + typeof credential.metadata?.accountId === "string" ? String(credential.metadata.accountId) : undefined, +} satisfies IntegrationOAuthMethodRegistration + export const SnowflakeCortexPlugin = define({ id: "snowflake-cortex", effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.update("snowflake-cortex", (integration) => { + integration.name = "Snowflake Cortex" + }) + draft.method.update(browser) + draft.method.update({ + integrationID: "snowflake-cortex", + method: { type: "key", label: "Paste PAT or bearer token manually" }, + }) + draft.method.update({ + integrationID: "snowflake-cortex", + method: { + type: "env", + names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"], + }, + }) + }) + + yield* ctx.catalog.transform((catalog) => { + const item = catalog.provider.get(ProviderV2.ID.make("snowflake-cortex")) + if (!item) return + catalog.provider.update(item.provider.id, (provider) => { + provider.integrationID = Integration.ID.make("snowflake-cortex") + }) + }) + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return @@ -86,4 +374,4 @@ export const SnowflakeCortexPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.Plugin) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20e..e1d9c5eb099d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -132,11 +132,34 @@ export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, ): Effect.Effect => { + // Key credentials may stash provider options (org, project, baseURL) in metadata. + // OAuth credentials may also carry routing metadata (e.g. Snowflake accountId → + // per-account Cortex base URL). Apply both at request time so login methods that + // persist account-scoped hosts work without a static catalog URL. + const metadata = credential?.metadata const resolved = - credential?.type !== "key" || credential.metadata === undefined + !credential || metadata === undefined ? model : produce(model, (draft) => { - Object.assign(draft.request.body, credential.metadata) + if (credential.type === "key") { + Object.assign(draft.request.body, metadata) + } + if (credential.type === "oauth") { + const accountId = typeof metadata.accountId === "string" ? metadata.accountId : undefined + const baseURL = + typeof metadata.baseURL === "string" + ? metadata.baseURL + : accountId + ? `https://${accountId}.snowflakecomputing.com/api/v2/cortex/v1` + : undefined + if ( + baseURL && + (draft.providerID === ProviderV2.ID.make("snowflake-cortex") || typeof metadata.baseURL === "string") + ) { + if (draft.api.type === "aisdk") draft.api = { ...draft.api, url: baseURL } + else if (draft.api.type === "native") draft.api = { ...draft.api, url: baseURL } + } + } }) const key = apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index cd67feb40e8b..3d0d85271907 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,10 +1,17 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" +import { + SnowflakeCortexPlugin, + cortexBaseURL, + cortexFetch, + normalizeAccount, + oauthScope, +} from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" @@ -49,6 +56,37 @@ describe("SnowflakeCortexPlugin", () => { }), ) + it.effect("registers browser OAuth, PAT key, and env methods", () => + Effect.gen(function* () { + yield* addPlugin() + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")) + expect(integration?.name).toBe("Snowflake Cortex") + expect(integration?.methods).toEqual([ + { + id: Integration.MethodID.make("snowflake-browser"), + type: "oauth", + label: "Login with Snowflake (External Browser)", + prompts: [ + { + type: "text", + key: "account", + message: "Snowflake Account Identifier", + placeholder: "myorg-myaccount", + }, + { + type: "text", + key: "role", + message: "Snowflake Role (optional)", + placeholder: "PUBLIC", + }, + ], + }, + { type: "key", label: "Paste PAT or bearer token manually" }, + { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] }, + ]) + }), + ) + it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service @@ -170,6 +208,28 @@ describe("SnowflakeCortexPlugin", () => { ) }) +describe("Snowflake OAuth helpers", () => { + bun_it("normalizeAccount strips scheme, host suffix, and trailing slashes", () => { + expect(normalizeAccount(" myorg-myaccount ")).toBe("myorg-myaccount") + expect(normalizeAccount("https://myorg-myaccount.snowflakecomputing.com")).toBe("myorg-myaccount") + expect(normalizeAccount("https://myorg-myaccount.snowflakecomputing.com/")).toBe("myorg-myaccount") + expect(normalizeAccount("myorg-myaccount.snowflakecomputing.com")).toBe("myorg-myaccount") + expect(normalizeAccount("")).toBe("") + }) + + bun_it("oauthScope uses Snowflake-compatible role encoding", () => { + expect(oauthScope(undefined)).toBe("refresh_token") + expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC") + expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE") + }) + + bun_it("cortexBaseURL builds the per-account Cortex OpenAI-compatible host", () => { + expect(cortexBaseURL("myorg-myaccount")).toBe( + "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1", + ) + }) +}) + type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise describe("cortexFetch", () => { diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a381..80573db0372f 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -313,6 +313,67 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("derives Snowflake Cortex base URL from OAuth account metadata", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai-compatible" }), + id: ModelV2.ID.make("claude-sonnet-4-6"), + providerID: ProviderV2.ID.make("snowflake-cortex"), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("snowflake-browser"), + access: "snowflake-access", + refresh: "snowflake-refresh", + expires: Date.now() + 60_000, + metadata: { accountId: "myorg-myaccount" }, + }), + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1/chat/completions", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route).toMatchObject({ + id: "openai-compatible-chat", + endpoint: { baseURL: "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1" }, + }) + expect(headers.authorization).toBe("Bearer snowflake-access") + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + + it.effect("prefers explicit OAuth baseURL metadata over account-derived Snowflake host", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://placeholder.example/v1" }), + id: ModelV2.ID.make("claude-sonnet-4-6"), + providerID: ProviderV2.ID.make("snowflake-cortex"), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("snowflake-browser"), + access: "snowflake-access", + refresh: "snowflake-refresh", + expires: Date.now() + 60_000, + metadata: { + accountId: "myorg-myaccount", + baseURL: "https://custom.example/cortex/v1", + }, + }), + ) + + expect(resolved.route.endpoint).toMatchObject({ baseURL: "https://custom.example/cortex/v1" }) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel(