From 8a02390178c6a9e9ca4f1f98560f6e846cc8d347 Mon Sep 17 00:00:00 2001 From: Max Sonderby Date: Sat, 16 May 2026 02:36:10 -0700 Subject: [PATCH] feat: add case.dev and Core as /login auth providers Register case.dev (device flow) and Core (OAuth device flow) as OAuth providers so they appear in the interactive /login selector dropdown. Co-Authored-By: Claude Opus 4.6 --- .../coding-agent/src/core/casedev-oauth.ts | 232 ++++++++++++++++++ .../coding-agent/src/core/model-registry.ts | 3 + packages/coding-agent/src/main.ts | 6 + 3 files changed, 241 insertions(+) create mode 100644 packages/coding-agent/src/core/casedev-oauth.ts diff --git a/packages/coding-agent/src/core/casedev-oauth.ts b/packages/coding-agent/src/core/casedev-oauth.ts new file mode 100644 index 000000000..48c8fc36c --- /dev/null +++ b/packages/coding-agent/src/core/casedev-oauth.ts @@ -0,0 +1,232 @@ +/** + * OAuth providers for case.dev and Core device-flow authentication. + * These register into the OAuth provider registry so they appear in /login. + */ + +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "@casemark/linc-ai"; + +const CASEDEV_API_BASE = process.env.CASEDEV_API_BASE_URL || "https://api.case.dev"; +const CORE_API_BASE = process.env.CORE_API_BASE_URL || "https://core.case.dev"; +const CORE_OAUTH_CLIENT_ID = process.env.LINC_CORE_OAUTH_CLIENT_ID || "linc"; +const CORE_OAUTH_SCOPE = process.env.LINC_CORE_OAUTH_SCOPE || "core:chat"; + +const FAR_FUTURE = Date.now() + 365 * 24 * 60 * 60 * 1000; + +interface DeviceFlowStartResponse { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + interval: number; + expiresIn: number; + expiresAt: string; +} + +interface DeviceFlowPollResponse { + error?: string; + interval?: number; + tokenType?: string; + apiKey?: string; + expiresAt?: string | null; + scope?: { services: Array<{ service: string; scopes: string[] }> }; +} + +interface CoreDeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + interval?: number; + expires_in: number; +} + +interface CoreTokenResponse { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + error?: string; + error_description?: string; +} + +export const casedevOAuthProvider: OAuthProviderInterface = { + id: "casedev", + name: "case.dev", + + async login(callbacks: OAuthLoginCallbacks): Promise { + callbacks.onProgress?.("Starting device authorization..."); + + const startRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scopes: { services: [{ service: "all", scopes: ["read", "write"] }] }, + }), + signal: callbacks.signal, + }); + + if (!startRes.ok) { + throw new Error(`Failed to start device flow: ${startRes.status} ${startRes.statusText}`); + } + + const start = (await startRes.json()) as DeviceFlowStartResponse; + callbacks.onAuth({ url: start.verificationUriComplete }); + callbacks.onProgress?.("Waiting for approval..."); + + const pollInterval = (start.interval || 3) * 1000; + const expiresAt = new Date(start.expiresAt).getTime(); + + while (Date.now() < expiresAt) { + callbacks.signal?.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const pollRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/poll`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceCode: start.deviceCode }), + signal: callbacks.signal, + }); + + if (pollRes.status === 200) { + const result = (await pollRes.json()) as DeviceFlowPollResponse; + if (result.apiKey) { + return { access: result.apiKey, refresh: "", expires: FAR_FUTURE }; + } + } + + if (pollRes.status === 429 || pollRes.status === 202) { + continue; + } + + if (pollRes.status === 403 || pollRes.status === 410) { + throw new Error("Authorization denied or expired."); + } + } + + throw new Error("Authorization timed out."); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return credentials; + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; + +export const coreOAuthProvider: OAuthProviderInterface = { + id: "core", + name: "Core", + + async login(callbacks: OAuthLoginCallbacks): Promise { + callbacks.onProgress?.("Starting Core device authorization..."); + + const startRes = await fetch(`${CORE_API_BASE}/oauth/device/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: CORE_OAUTH_CLIENT_ID, + scope: CORE_OAUTH_SCOPE, + }), + signal: callbacks.signal, + }); + + if (!startRes.ok) { + throw new Error(`Failed to start Core device flow: ${startRes.status} ${startRes.statusText}`); + } + + const start = (await startRes.json()) as CoreDeviceCodeResponse; + if (!start.device_code || !start.verification_uri_complete) { + throw new Error("Invalid device code response from Core."); + } + + callbacks.onAuth({ url: start.verification_uri_complete }); + callbacks.onProgress?.("Waiting for approval..."); + + let pollIntervalMs = Math.max(1, start.interval || 5) * 1000; + const expiresAt = Date.now() + start.expires_in * 1000; + + while (Date.now() < expiresAt) { + callbacks.signal?.throwIfAborted(); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + + let pollRes: Response; + try { + pollRes = await fetch(`${CORE_API_BASE}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: CORE_OAUTH_CLIENT_ID, + device_code: start.device_code, + }), + signal: callbacks.signal, + }); + } catch { + continue; + } + + const result = (await pollRes.json()) as CoreTokenResponse; + + if (pollRes.ok && result.access_token) { + const expiresIn = result.expires_in ?? 3600; + return { + access: result.access_token, + refresh: result.refresh_token ?? "", + expires: Date.now() + expiresIn * 1000, + }; + } + + const errorCode = result.error; + if (!errorCode || errorCode === "authorization_pending") { + continue; + } + if (errorCode === "slow_down") { + pollIntervalMs += 1000; + continue; + } + if (errorCode === "access_denied" || errorCode === "invalid_grant") { + throw new Error("Authorization denied or expired."); + } + + const description = result.error_description ? `: ${result.error_description}` : ""; + throw new Error(`Core OAuth error: ${errorCode}${description}`); + } + + throw new Error("Authorization timed out."); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + if (!credentials.refresh) { + return credentials; + } + + const res = await fetch(`${CORE_API_BASE}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CORE_OAUTH_CLIENT_ID, + refresh_token: credentials.refresh, + }), + }); + + const result = (await res.json()) as CoreTokenResponse; + if (!res.ok || !result.access_token) { + throw new Error(result.error_description || result.error || "Token refresh failed"); + } + + const expiresIn = result.expires_in ?? 3600; + return { + access: result.access_token, + refresh: result.refresh_token ?? credentials.refresh, + expires: Date.now() + expiresIn * 1000, + }; + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index ba1d790bc..9a477af8d 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -24,6 +24,7 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { getAgentDir } from "../config.js"; import type { AuthStorage } from "./auth-storage.js"; +import { casedevOAuthProvider, coreOAuthProvider } from "./casedev-oauth.js"; import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js"; const Ajv = (AjvModule as any).default || AjvModule; @@ -295,6 +296,8 @@ export class ModelRegistry { // Ensure dynamic API/OAuth registrations are rebuilt from current provider state. resetApiProviders(); resetOAuthProviders(); + registerOAuthProvider(casedevOAuthProvider); + registerOAuthProvider(coreOAuthProvider); this.loadModels(); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 2f08d4270..f573dccd4 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,6 +7,7 @@ import { execSync, spawn } from "node:child_process"; import { type ImageContent, loadModels, modelsAreEqual, supportsXhigh } from "@casemark/linc-ai"; +import { registerOAuthProvider } from "@casemark/linc-ai/oauth"; import chalk from "chalk"; import { createInterface } from "readline"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; @@ -18,6 +19,7 @@ import { ensureAuthenticated, runLogin } from "./cli/login.js"; import { selectSession } from "./cli/session-picker.js"; import { APP_NAME, getAgentDir, getModelsPath, getWebUiExampleDir, VERSION } from "./config.js"; import { AuthStorage } from "./core/auth-storage.js"; +import { casedevOAuthProvider, coreOAuthProvider } from "./core/casedev-oauth.js"; import { exportFromFile } from "./core/export-html/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js"; import { migrateKeybindingsConfigFile } from "./core/keybindings.js"; @@ -815,6 +817,10 @@ export async function main(args: string[]) { const modelRegistry = new ModelRegistry(authStorage, getModelsPath()); + // Register case.dev and Core as auth providers for /login + registerOAuthProvider(casedevOAuthProvider); + registerOAuthProvider(coreOAuthProvider); + const resourceLoader = new DefaultResourceLoader({ cwd, agentDir,