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
37 changes: 27 additions & 10 deletions packages/codev-gateway/src/auth-file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto"
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { dirname, join } from "node:path"

Expand Down Expand Up @@ -27,12 +27,15 @@ export interface CodevConfig {
gatewayUrl: string
}

// ~/.codev/auth.json is SHARED with the codev hub: identical path and shape
// (hub src/lib/auth.ts AuthFileContents), so a hub installed later reuses the
// session this flow created and vice versa. Three independent blocks — SSO
// tokens, gateway API key, CoDev runtime config — each updated in isolation so
// saving one never clobbers the others. skillhub_cookie is hub-only; the
// read-merge-write pattern preserves it untouched.
// The auth file is SHARED with the codev hub: identical shape (hub
// src/lib/auth.ts AuthFileContents). The hub owns ~/.codev-hub/auth.json; when
// that file exists we read AND write it — writes must follow reads because a
// silent refresh may rotate the refresh_token, and persisting the rotated
// token anywhere else would strand the hub on a dead session. Without a hub
// file we fall back to our own ~/.codev/auth.json. Three independent blocks —
// SSO tokens, gateway API key, CoDev runtime config — each updated in
// isolation so saving one never clobbers the others. skillhub_cookie is
// hub-only; the read-merge-write pattern preserves it untouched.
interface AuthFileContents {
access_token?: string
id_token?: string
Expand All @@ -48,15 +51,29 @@ interface AuthFileContents {
skillhub_cookie?: string
}

// Keyed off the hub's auth.json rather than the ~/.codev-hub/ dir (mirroring
// the hub's own fresh-install check): unrelated code creates the directory,
// so only the file signals a hub session worth adopting. Home is resolved per
// call via OPENCODE_TEST_HOME (same convention as core's Global) because
// Bun's homedir() ignores runtime HOME changes, which tests rely on.
function authDir() {
const home = process.env.OPENCODE_TEST_HOME ?? homedir()
const hub = join(home, ".codev-hub")
if (existsSync(join(hub, "auth.json"))) return hub
return join(home, ".codev")
}

export function authFilePath() {
return join(homedir(), ".codev", "auth.json")
return join(authDir(), "auth.json")
}

// Written by the hub's `codevhub logout` (revoking tokens doesn't end the
// IdP's browser session); honored here so a post-logout login re-prompts for
// credentials instead of silently riding the session cookie.
// credentials instead of silently riding the session cookie. Resolved against
// the same dir as auth.json so we honor the hub's sentinel when riding its
// session.
export function forceLoginPath() {
return join(homedir(), ".codev", "force-login")
return join(authDir(), "force-login")
}

export function readAuthFile(): AuthFileContents | null {
Expand Down
3 changes: 2 additions & 1 deletion packages/codev-gateway/src/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export interface LoginOptions {
/**
* OAuth2 Authorization Code flow with PKCE against the CoDev SSO wrapper,
* ported from the hub (src/lib/auth.ts) so both tools share one session in
* ~/.codev/auth.json:
* the resolved auth file (the hub's ~/.codev-hub/auth.json when present, else
* our own ~/.codev/auth.json):
* 1. Reuse cached tokens if still valid
* 2. Try a silent refresh if a refresh_token is on disk
* 3. Otherwise: loopback HTTP server, /authorize with state + nonce + PKCE,
Expand Down
55 changes: 47 additions & 8 deletions packages/codev-gateway/test/auth-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@ import { tmpdir } from "node:os"
import { join } from "node:path"

let tempDir: string
let originalHome: string | undefined
let originalProfile: string | undefined
let originalTestHome: string | undefined

// OPENCODE_TEST_HOME rather than HOME: Bun's homedir() ignores runtime HOME
// changes, so overriding HOME here would silently hit the real ~/.codev.
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-auth-"))
originalHome = process.env.HOME
originalProfile = process.env.USERPROFILE
process.env.HOME = tempDir
process.env.USERPROFILE = tempDir
originalTestHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tempDir
})

afterEach(() => {
process.env.HOME = originalHome
process.env.USERPROFILE = originalProfile
if (originalTestHome === undefined) delete process.env.OPENCODE_TEST_HOME
if (originalTestHome !== undefined) process.env.OPENCODE_TEST_HOME = originalTestHome
rmSync(tempDir, { recursive: true, force: true })
})

Expand Down Expand Up @@ -51,6 +50,46 @@ describe("auth-file", () => {
expect(statSync(authFilePath()).mode & 0o777).toBe(0o600)
})

test("prefers ~/.codev-hub only when its auth.json exists", async () => {
const { authFilePath, forceLoginPath } = await import("../src/auth-file")
const { mkdirSync, writeFileSync } = await import("node:fs")
expect(authFilePath()).toBe(join(tempDir, ".codev", "auth.json"))

// A bare ~/.codev-hub dir (created by unrelated hub code) is not a session.
mkdirSync(join(tempDir, ".codev-hub"), { recursive: true })
expect(authFilePath()).toBe(join(tempDir, ".codev", "auth.json"))
expect(forceLoginPath()).toBe(join(tempDir, ".codev", "force-login"))

writeFileSync(join(tempDir, ".codev-hub", "auth.json"), "{}")
expect(authFilePath()).toBe(join(tempDir, ".codev-hub", "auth.json"))
expect(forceLoginPath()).toBe(join(tempDir, ".codev-hub", "force-login"))
})

test("reads a hub session and writes back into the hub file", async () => {
const { loadAuth, saveApiKey } = await import("../src/auth-file")
const { existsSync, mkdirSync, writeFileSync } = await import("node:fs")
mkdirSync(join(tempDir, ".codev-hub"), { recursive: true })
writeFileSync(
join(tempDir, ".codev-hub", "auth.json"),
JSON.stringify({
access_token: "hub-at",
id_token: "hub-it",
expires_at: Date.now() + 60_000,
user: { sub: "s", email: "hub@x", displayName: "Hub" },
skillhub_cookie: "skill-hub-session=abc",
}),
)

expect(loadAuth()?.user.email).toBe("hub@x")

saveApiKey({ apiKey: "sk-hub" })
const raw = JSON.parse(readFileSync(join(tempDir, ".codev-hub", "auth.json"), "utf-8"))
expect(raw.api_key).toBe("sk-hub")
expect(raw.access_token).toBe("hub-at")
expect(raw.skillhub_cookie).toBe("skill-hub-session=abc")
expect(existsSync(join(tempDir, ".codev", "auth.json"))).toBe(false)
})

test("loadAuth rejects expired or partial sessions", async () => {
const { loadAuth, saveAuth } = await import("../src/auth-file")
expect(loadAuth()).toBeNull()
Expand Down
Loading