Compare commits

...
Author SHA1 Message Date
Aiden Cline 4a4e3acb7e feat(core): add Snowflake Cortex browser OAuth login 2026-09-03 16:40:34 -05:00
2 changed files with 384 additions and 3 deletions
@@ -1,9 +1,32 @@
import { Effect } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
const providerID = Provider.ID.make("snowflake-cortex")
const integrationID = Integration.ID.make("snowflake-cortex")
const browserMethodID = Integration.MethodID.make("browser")
// Snowflake's built-in public OAuth client for local applications. It has no
// secret, so the token endpoint expects the client ID as both Basic credentials.
const clientID = "LOCAL_APPLICATION"
const callbackHost = "127.0.0.1"
// Snowflake OAuth access tokens live 10 minutes unless the response says otherwise.
const defaultTokenLifetime = 600
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
// Exported for testing: intercepts Cortex-specific request/response quirks.
export function cortexFetch(upstream: FetchLike = fetch) {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
@@ -64,13 +87,118 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
}
const browser = (app: App.Info) =>
({
integrationID,
method: {
id: browserMethodID,
type: "oauth",
label: "Login with Snowflake (External Browser)",
form: [
{
type: "string",
key: "account",
title: "Snowflake account identifier",
placeholder: "myorg-myaccount",
required: true,
},
{ type: "string", key: "role", title: "Snowflake role (optional)", placeholder: "PUBLIC" },
],
},
authorize: (answer) =>
Effect.gen(function* () {
const account = normalizeAccount(answer.account)
if (!account) return yield* Effect.fail(new Error("Snowflake account identifier is required"))
const role = typeof answer.role === "string" ? answer.role.trim() : ""
const pkce = yield* Effect.promise(generatePKCE)
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const code = yield* Deferred.make<string, Error>()
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
if (url.pathname !== "/") {
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 port = yield* listen(server)
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://${callbackHost}:${port}/`
return {
mode: "auto" as const,
url: `${issuer(account)}/oauth/authorize?${new URLSearchParams({
client_id: clientID,
response_type: "code",
redirect_uri: redirect,
scope: scope(role),
state,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})}`,
instructions:
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
token(
account,
{
grant_type: "authorization_code",
code: value,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
},
app,
),
),
Effect.flatMap((tokens) => credential(tokens, account)),
),
}
}),
refresh: (value) => {
const account = value.metadata?.account
if (typeof account !== "string") return Effect.fail(new Error("Snowflake credential is missing its account"))
return token(
account,
{ grant_type: "refresh_token", refresh_token: value.refresh, client_id: clientID },
app,
).pipe(Effect.flatMap((tokens) => credential(tokens, account, value.refresh)))
},
label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined),
}) satisfies IntegrationOAuthMethodRegistration
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake.cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((editor) => {
editor.method.update(browser(ctx.app))
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.make("snowflake-cortex")) return
if (evt.model.providerID !== providerID) return
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
@@ -88,3 +216,98 @@ export const SnowflakeCortexPlugin = define({
)
}),
})
function normalizeAccount(value: unknown) {
if (typeof value !== "string") return ""
return value
.trim()
.replace(/^https?:\/\//, "")
.replace(/\.snowflakecomputing\.com\/?$/, "")
.replace(/\/+$/, "")
}
function issuer(account: string) {
return `https://${account}.snowflakecomputing.com`
}
// Roles outside Snowflake's unquoted identifier charset must use the encoded scope form.
function scope(role: string) {
if (!role) return "refresh_token"
if (/^[-_A-Za-z0-9]+$/.test(role)) return `refresh_token session:role:${role}`
return `refresh_token session:role-encoded:${encodeURIComponent(role)}`
}
function listen(server: Server) {
return Effect.callback<number, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(0, callbackHost, () => {
server.off("error", onError)
const address = server.address()
resume(
address && typeof address === "object"
? Effect.succeed(address.port)
: Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")),
)
})
})
}
function token(account: string, form: Record<string, string>, app: App.Info) {
return Effect.tryPromise({
try: (signal) =>
fetch(`${issuer(account)}/oauth/token-request`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": App.useragent(app),
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
},
body: new URLSearchParams(form).toString(),
signal,
}),
catch: (cause) => cause,
}).pipe(
Effect.flatMap((response) => {
if (response.ok) return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function credential(tokens: Token, account: string, current?: string) {
const refresh = tokens.refresh_token ?? current
if (!refresh) {
return Effect.fail(
new Error(
"Snowflake token response did not include refresh_token. Ensure the OAuth security integration issues refresh tokens.",
),
)
}
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID: browserMethodID,
access: tokens.access_token,
refresh,
expires: Date.now() + (tokens.expires_in ?? defaultTokenLifetime) * 1000,
// The model resolver projects OAuth metadata into provider settings, so
// baseURL here replaces the catalog's ${SNOWFLAKE_ACCOUNT} template.
metadata: { account, baseURL: `${issuer(account)}/api/v2/cortex/v1` },
}),
)
}
async function generatePKCE() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(64)), (byte) => chars[byte % chars.length]).join("")
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
@@ -1,7 +1,12 @@
import { LLM } from "@opencode-ai/ai"
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schedule } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
@@ -171,6 +176,159 @@ describe("SnowflakeCortexPlugin", () => {
)
})
describe("SnowflakeCortexPlugin browser OAuth", () => {
const integrationID = Integration.ID.make("snowflake-cortex")
const methodID = Integration.MethodID.make("browser")
const connect = Effect.fn(function* (answer: Record<string, string>) {
const integrations = yield* Integration.Service
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answer })
return { attempt, url: new URL(attempt.url) }
})
const settled = Effect.fn(function* (attemptID: Integration.AttemptID) {
const integrations = yield* Integration.Service
const status = yield* integrations.oauth.status({ integrationID, attemptID })
if (status.status === "pending") return yield* Effect.fail(new Error("Snowflake authorization pending"))
return status
})
it.effect("registers the browser login method with account and role prompts", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{
id: methodID,
type: "oauth",
label: "Login with Snowflake (External Browser)",
form: [
{
type: "string",
key: "account",
title: "Snowflake account identifier",
placeholder: "myorg-myaccount",
required: true,
},
{ type: "string", key: "role", title: "Snowflake role (optional)", placeholder: "PUBLIC" },
],
},
])
}),
)
it.effect("builds an account-specific PKCE authorize URL with a loopback redirect", () =>
Effect.gen(function* () {
yield* addPlugin()
const { attempt, url } = yield* connect({ account: "myorg-myaccount" })
expect(attempt.mode).toBe("auto")
expect(url.origin).toBe("https://myorg-myaccount.snowflakecomputing.com")
expect(url.pathname).toBe("/oauth/authorize")
expect(url.searchParams.get("client_id")).toBe("LOCAL_APPLICATION")
expect(url.searchParams.get("response_type")).toBe("code")
expect(url.searchParams.get("scope")).toBe("refresh_token")
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]{43}$/)
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
expect(redirect.hostname).toBe("127.0.0.1")
expect(redirect.pathname).toBe("/")
expect(Number(redirect.port)).toBeGreaterThan(0)
}),
)
it.effect("normalizes account URLs and encodes roles outside the identifier charset", () =>
Effect.gen(function* () {
yield* addPlugin()
const plain = yield* connect({ account: "https://myorg-myaccount.snowflakecomputing.com/", role: "ANALYST" })
expect(plain.url.origin).toBe("https://myorg-myaccount.snowflakecomputing.com")
expect(plain.url.searchParams.get("scope")).toBe("refresh_token session:role:ANALYST")
const quoted = yield* connect({ account: "myorg-myaccount", role: "My Role" })
expect(quoted.url.searchParams.get("scope")).toBe("refresh_token session:role-encoded:My%20Role")
}),
)
it.effect("rejects an account identifier that normalizes to nothing", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const exit = yield* integrations.oauth
.connect({ integrationID, methodID, answer: { account: "https://.snowflakecomputing.com" } })
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
it.effect("resolves the catalog account template and bearer token from the OAuth credential", () =>
withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
Model.Info.make({
...Model.Info.default(Provider.ID.make("snowflake-cortex"), Model.ID.make("claude-sonnet-4-6")),
modelID: Model.ID.make("claude-sonnet-4-6"),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://${SNOWFLAKE_ACCOUNT}.snowflakecomputing.com/api/v2/cortex/v1" },
}),
Credential.OAuth.make({
type: "oauth",
methodID,
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 600_000,
metadata: {
account: "myorg-myaccount",
baseURL: "https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1",
},
}),
)
expect(resolved.route.endpoint.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1")
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(headers.authorization).toBe("Bearer oauth-access")
}),
),
)
it.live("fails the attempt when the loopback callback reports a provider error", () =>
Effect.gen(function* () {
yield* addPlugin()
const { attempt, url } = yield* connect({ account: "myorg-myaccount" })
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
redirect.searchParams.set("error", "access_denied")
redirect.searchParams.set("error_description", "User denied access")
redirect.searchParams.set("state", url.searchParams.get("state") ?? "")
const response = yield* Effect.promise(() => fetch(redirect))
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.text())).toContain("User denied access")
const status = yield* settled(attempt.attemptID).pipe(
Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }),
)
expect(status).toMatchObject({ status: "failed", message: "User denied access" })
}),
)
it.live("rejects a loopback callback whose state does not match", () =>
Effect.gen(function* () {
yield* addPlugin()
const { attempt, url } = yield* connect({ account: "myorg-myaccount" })
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
redirect.searchParams.set("code", "forged")
redirect.searchParams.set("state", "wrong")
const response = yield* Effect.promise(() => fetch(redirect))
expect(response.status).toBe(400)
const status = yield* settled(attempt.attemptID).pipe(
Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }),
)
expect(status).toMatchObject({ status: "failed", message: "Invalid OAuth state" })
}),
)
})
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
describe("cortexFetch", () => {