-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add case.dev and Core to /login dropdown #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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"] }] }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this provider is selected from interactive 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; | ||
| }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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[]) { | |
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
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.