Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions packages/coding-agent/src/core/casedev-oauth.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Device-flow request/response types and polling logic duplicate the CLI login flow, increasing drift risk and maintenance cost. Repo-wide grep shows DeviceFlowStartResponse/DeviceFlowPollResponse/CoreDeviceCodeResponse/CoreTokenResponse appear only here and in packages/coding-agent/src/cli/login.ts, and the auth/cli endpoints are referenced only in those two files.

Suggestion: Collapse by extracting shared device-flow helpers (types + polling) into a small module and have both the OAuth providers and CLI login call it, keeping exact error/timeout behavior.

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<OAuthCredentials> {
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"] }] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Device-flow scope is hardcoded to full read/write for all services, which violates least-privilege and makes access breadth non-configurable.

Suggestion: Make scopes configurable (env/config) and default to the minimum required scope for CLI login; document expected scopes.

}),
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Polling ignores non-200/202/429/403/410 responses; server errors or malformed responses will silently loop until timeout, obscuring the real failure state.

Suggestion: Handle other non-2xx statuses explicitly with a descriptive error (include status code) or a bounded retry with backoff and a final failure message.

const result = (await pollRes.json()) as DeviceFlowPollResponse;
if (result.apiKey) {
return { access: result.apiKey, refresh: "", expires: FAR_FUTURE };
}
Comment on lines +93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist login tokens where requests can read them

When this provider is selected from interactive /login, AuthStorage.login() stores the returned value as { type: "oauth", access: ... }, but the actual request path still only reads auth.json when data.casedev.type === "api_key" in AuthStorage.getApiKey() and isAuthenticated(). After this flow succeeds, hasAuth("casedev") can mark models available, but getApiKey() returns undefined, so the next model call fails with no API key; the same storage mismatch affects the new Core OAuth token unless the auth storage path is taught to return OAuth access tokens or these flows save the token in the existing API-key slot.

Useful? React with 👍 / 👎.

}

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<OAuthCredentials> {
return credentials;
},

getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
};

export const coreOAuthProvider: OAuthProviderInterface = {
id: "core",
name: "Core",

async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
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<OAuthCredentials> {
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;
},
};
3 changes: 3 additions & 0 deletions packages/coding-agent/src/core/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -815,6 +817,10 @@ export async function main(args: string[]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INFO: Provider registration is performed in both main startup and ModelRegistry refresh, creating two registration paths that can drift or duplicate providers depending on registry behavior.

Suggestion: Centralize provider registration in one place (prefer ModelRegistry lifecycle) and ensure registration is idempotent to avoid duplicate entries after refresh.

const modelRegistry = new ModelRegistry(authStorage, getModelsPath());

// Register case.dev and Core as auth providers for /login

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Provider registration is duplicated in main startup and ModelRegistry.refresh, which is safe but repetitive. Repo-wide grep shows casedevOAuthProvider/coreOAuthProvider are only imported in main.ts and packages/coding-agent/src/core/model-registry.ts (besides their definition).

Suggestion: Collapse registration into a single helper (e.g., registerDefaultOAuthProviders) invoked from both the ModelRegistry constructor/refresh or from a shared initializer to remove duplication without behavior change.

registerOAuthProvider(casedevOAuthProvider);
registerOAuthProvider(coreOAuthProvider);

const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
Expand Down
Loading