Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ file is the human-readable summary of what changed and why.

### Gateway

- **The desktop sign-in keeps its fallback controls** — choosing **Login with
SSO** in the desktop app showed the **Open link**, **Copy** and paste-back
controls for a split second and then hid them, because the status line
logged while the browser was being opened wiped the sign-in URL they hang
off. If the browser never opened (no default browser, a remote session), the
only way out was **Back** or the five-minute timeout. The URL now survives
until the sign-in ends.
([#351](https://github.com/quickbeard/codev-code/pull/351))
- **Backing out of a sign-in actually stops it** — **Back** in the desktop
dialog, **Log out**, and now **esc** on the TUI's SSO step abort the attempt:
the local callback listener closes and a browser callback that lands
afterwards can no longer write a session behind your back (previously the
listener kept waiting for up to five minutes and a late callback would sign
you back in after a logout). In the TUI, escaping the SSO step returns to the
setup chooser while nothing is configured, and simply closes the dialog
otherwise — before, the step could only be left by quitting the app or
waiting for the timeout, which also quit the app.
([#351](https://github.com/quickbeard/codev-code/pull/351))
- **Desktop sign-in honours the system proxy end to end** — the token exchange
and silent refresh behind SSO now go through the same host-aware fetch as
the gateway calls, so proxies, PAC files and enterprise root certificates
apply to the whole flow. Before, only the gateway half did, and a corporate
proxy could pass the browser step and then fail the code exchange.
([#351](https://github.com/quickbeard/codev-code/pull/351))
- **GLM-5.3-Flash gets its served window** — the model is served with the same
262,144-token window as MiniMax-M3, so it now has its own entry in the
built-in table instead of falling back to the safe 200K default. Signing in
Expand Down
1 change: 1 addition & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export {
type ProviderIdentity,
} from "./provider"
export {
isLoginCancelled,
login,
logout,
parseCallbackParams,
Expand Down
107 changes: 89 additions & 18 deletions packages/codev-gateway/src/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,41 @@ export interface LoginOptions {
// Browser launcher; the TUI passes its own `open` util. Fire-and-forget —
// the loopback server resolves the flow whether or not a browser launched.
openUrl: (url: string) => Promise<unknown>
// Callers embedded in Electron pass the host-aware net.fetch so the token
// exchange rides the same proxies and PAC files as the backend calls;
// everything else uses the runtime's fetch.
fetchImpl?: typeof fetch
// Aborting closes the loopback server and rejects with a cancellation
// (isLoginCancelled) at whichever step the flow is on, so a sign-in the user
// backed out of can't complete later and write a session behind their back.
signal?: AbortSignal
}

// Whether a login() rejection is the caller's own abort rather than a failure
// worth surfacing.
export function isLoginCancelled(error: unknown): boolean {
return error instanceof Error && error.name === "LoginCancelled"
}

function loginCancelled(): Error {
const error = new Error("Login cancelled")
error.name = "LoginCancelled"
return error
}

// Combines the per-request timeout with the caller's abort, when there is one.
function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs)
return signal ? AbortSignal.any([timeout, signal]) : timeout
}

// An abort mid-request surfaces as the fetch's own AbortError; report it as
// the cancellation it is so callers don't show it as a failure.
function rethrow(signal?: AbortSignal) {
return (error: unknown): never => {
if (signal?.aborted) throw loginCancelled()
throw error
}
}

/**
Expand Down Expand Up @@ -91,11 +126,12 @@ export async function login(options: LoginOptions): Promise<AuthData> {
if (stale?.refresh_token) {
try {
log("Refreshing session...")
const authData = await refreshToAuthData(stale.refresh_token)
const authData = await refreshToAuthData(stale.refresh_token, options.fetchImpl, options.signal)
saveAuth(authData)
log(`Logged in as ${authData.user.email}`)
return authData
} catch {
if (options.signal?.aborted) throw loginCancelled()
log("Refresh failed, starting full login...")
}
}
Expand All @@ -113,8 +149,10 @@ export async function login(options: LoginOptions): Promise<AuthData> {

const { code, redirectUri } = await getAuthCode(options, state, challenge, nonce, forceLogin)

const tokenRes = await exchangeCode(code, redirectUri, verifier)
const user = await fetchUserInfo(tokenRes.access_token)
const tokenRes = await exchangeCode(code, redirectUri, verifier, options).catch(rethrow(options.signal))
const user = await fetchUserInfo(tokenRes.access_token, options.fetchImpl, options.signal).catch(
rethrow(options.signal),
)

const authData: AuthData = {
access_token: tokenRes.access_token,
Expand All @@ -128,6 +166,8 @@ export async function login(options: LoginOptions): Promise<AuthData> {
},
}

// The last window an abort can land in: nothing may reach disk after it.
if (options.signal?.aborted) throw loginCancelled()
saveAuth(authData)
clearForceLogin()
log(`Logged in as ${authData.user.email}`)
Expand All @@ -137,13 +177,13 @@ export async function login(options: LoginOptions): Promise<AuthData> {
// Non-interactive SSO: reuse a non-expired cached session, else silently
// refresh via the stored refresh_token. Returns null when neither works —
// callers then run the interactive login().
export async function silentSso(): Promise<AuthData | null> {
export async function silentSso(opts?: { fetchImpl?: typeof fetch }): Promise<AuthData | null> {
const cached = loadAuth()
if (cached) return cached
const stale = readAuthFile()
if (!stale?.refresh_token) return null
try {
const authData = await refreshToAuthData(stale.refresh_token)
const authData = await refreshToAuthData(stale.refresh_token, opts?.fetchImpl)
saveAuth(authData)
return authData
} catch {
Expand Down Expand Up @@ -230,9 +270,13 @@ async function revokeToken(fetchImpl: typeof fetch, token: string | undefined, h
}).catch(() => {})
}

async function refreshToAuthData(refreshToken: string): Promise<AuthData> {
const refreshed = await refreshTokens(refreshToken)
const user = await fetchUserInfo(refreshed.access_token)
async function refreshToAuthData(
refreshToken: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<AuthData> {
const refreshed = await refreshTokens(refreshToken, fetchImpl, signal)
const user = await fetchUserInfo(refreshed.access_token, fetchImpl, signal)
return {
access_token: refreshed.access_token,
id_token: refreshed.id_token,
Expand Down Expand Up @@ -297,14 +341,17 @@ function getAuthCode(
): Promise<{ code: string; redirectUri: string }> {
return new Promise((resolve, reject) => {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
// Set once the flow resolves or rejects — by the loopback callback or by a
// manual paste — so the two paths can't both fire.
// Set once the flow resolves or rejects — by the loopback callback, a
// manual paste, the timeout, or the caller's abort — so no two paths can
// both fire.
let settled = false
const onAbort = () => failWith(loginCancelled())
const finish = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
timeoutHandle = null
}
options.signal?.removeEventListener("abort", onAbort)
}

// Captured once when listen() completes — server.address() returns null
Expand Down Expand Up @@ -430,7 +477,21 @@ function getAuthCode(
succeed(code)
})

if (options.signal?.aborted) {
reject(loginCancelled())
return
}
// Armed before listen() so an abort that lands while the port is still
// being bound closes the server instead of leaving it listening.
options.signal?.addEventListener("abort", onAbort, { once: true })

server.listen(0, "127.0.0.1", () => {
// Aborted while the port was being bound: the abort's close() found no
// running server, so close the one that just came up.
if (settled) {
server.close()
return
}
boundPort = (server.address() as AddressInfo).port
// Local browser: loopback authorize (on force-login via the /logout
// bounce that clears the IdP cookie first). Manual channel: the public
Expand Down Expand Up @@ -462,8 +523,14 @@ function getAuthCode(
})
}

async function exchangeCode(code: string, redirectUri: string, codeVerifier: string): Promise<TokenResponse> {
const res = await fetch(`${SSO_URL}/token`, {
async function exchangeCode(
code: string,
redirectUri: string,
codeVerifier: string,
options: Pick<LoginOptions, "fetchImpl" | "signal">,
): Promise<TokenResponse> {
const fetchImpl = options.fetchImpl ?? fetch
const res = await fetchImpl(`${SSO_URL}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
Expand All @@ -473,33 +540,37 @@ async function exchangeCode(code: string, redirectUri: string, codeVerifier: str
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS),
signal: requestSignal(SSO_FETCH_TIMEOUT_MS, options.signal),
})
if (!res.ok) {
throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`)
}
return (await res.json()) as TokenResponse
}

async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const res = await fetch(`${SSO_URL}/token`, {
async function refreshTokens(
refreshToken: string,
fetchImpl: typeof fetch,
signal?: AbortSignal,
): Promise<TokenResponse> {
const res = await fetchImpl(`${SSO_URL}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS),
signal: requestSignal(SSO_FETCH_TIMEOUT_MS, signal),
})
if (!res.ok) throw new Error(`Token refresh failed (${res.status})`)
return (await res.json()) as TokenResponse
}

async function fetchUserInfo(accessToken: string, fetchImpl: typeof fetch = fetch) {
async function fetchUserInfo(accessToken: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal) {
const res = await fetchImpl(`${SSO_URL}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS),
signal: requestSignal(SSO_FETCH_TIMEOUT_MS, signal),
})
if (!res.ok) {
throw new Error(`Failed to fetch user info (${res.status}): ${await res.text()}`)
Expand Down
97 changes: 96 additions & 1 deletion packages/codev-gateway/test/sso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { logout, parseCallbackParams, probeSession } from "../src/sso"
import { isLoginCancelled, login, logout, parseCallbackParams, probeSession, silentSso } from "../src/sso"
import { authFilePath, forceLoginPath, loadApiKey, saveApiKey, saveAuth } from "../src/auth-file"

describe("parseCallbackParams", () => {
Expand Down Expand Up @@ -164,3 +164,98 @@ describe("probeSession", () => {
expect(await probeSession({ fetchImpl: noFetch })).toBe("live")
})
})

describe("login", () => {
let tempDir: string
let originalTestHome: string | undefined

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-login-"))
originalTestHome = process.env.CODEV_TEST_HOME
process.env.CODEV_TEST_HOME = tempDir
})

afterEach(() => {
if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME
if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome
rmSync(tempDir, { recursive: true, force: true })
})

const user = { sub: "s", email: "e@x", displayName: "E" }
const noFetch: typeof fetch = (async (_input: string | URL | Request): Promise<Response> => {
throw new Error("must not be called")
}) as typeof fetch

// The interactive flow up to onReady is pure loopback: no IdP round-trip
// happens until a callback or paste lands, so the abort contract can be
// exercised against the real login() with the browser launcher stubbed.
test("abort rejects as a cancellation and closes the loopback server", async () => {
const abort = new AbortController()
const ready = new Promise<string>((resolve) => {
void login({
onLog: () => {},
onReady: (open) => open(),
openUrl: async (url) => resolve(url),
fetchImpl: noFetch,
signal: abort.signal,
}).then(
() => {
throw new Error("must not resolve")
},
(error) => {
expect(isLoginCancelled(error)).toBe(true)
},
)
})
// openUrl receives the loopback URL (a fresh home has no auth.json, so it
// is the force-login bounce whose redirect_uri names the callback port).
const port = new URL(new URL(await ready).searchParams.get("redirect_uri")!).port
expect(await loopbackAlive(port)).toBe(true)
abort.abort()
await new Promise((resolve) => setTimeout(resolve, 20))
expect(await loopbackAlive(port)).toBe(false)
expect(existsSync(authFilePath())).toBe(false)
})

test("an already-aborted signal never opens a server", async () => {
const abort = new AbortController()
abort.abort()
let opened = false
const error = await login({
onLog: () => {},
onReady: () => {
opened = true
},
openUrl: async () => {
opened = true
},
fetchImpl: noFetch,
signal: abort.signal,
}).catch((error) => error)
expect(isLoginCancelled(error)).toBe(true)
expect(opened).toBe(false)
})

test("the silent refresh and its userinfo round-trip use the injected fetch", async () => {
saveAuth({ access_token: "old-at", id_token: "it", refresh_token: "old-rt", expires_at: Date.now() - 1000, user })
const urls: string[] = []
const fetchImpl: typeof fetch = (async (input: string | URL | Request) => {
urls.push(String(input))
if (urls.length === 1)
return Response.json({ access_token: "new-at", id_token: "new-it", refresh_token: "new-rt", expires_in: 3600 })
return Response.json(user)
}) as typeof fetch
const auth = await silentSso({ fetchImpl })
expect(auth?.access_token).toBe("new-at")
expect(urls.map((url) => new URL(url).pathname.split("/").at(-1))).toEqual(["token", "userinfo"])
// login() takes the same path before ever opening a browser.
const again = await login({ onLog: () => {}, onReady: () => {}, openUrl: async () => {}, fetchImpl: noFetch })
expect(again.access_token).toBe("new-at")
})
})

async function loopbackAlive(port: string): Promise<boolean> {
return fetch(`http://127.0.0.1:${port}/nope`, { signal: AbortSignal.timeout(500) })
.then(() => true)
.catch(() => false)
}
Loading
Loading