Skip to content

Commit b81f280

Browse files
committed
feat(auth): unify controller oauth flows
1 parent d9846c3 commit b81f280

57 files changed

Lines changed: 1744 additions & 368 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/api/src/api/contracts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,15 @@ export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth"
208208
export type AuthSnapshot = {
209209
readonly globalEnvPath: string
210210
readonly claudeAuthPath: string
211+
readonly codexAuthPath: string
211212
readonly geminiAuthPath: string
212213
readonly grokAuthPath: string
213214
readonly totalEntries: number
214215
readonly githubTokenEntries: number
215216
readonly gitTokenEntries: number
216217
readonly gitUserEntries: number
217218
readonly claudeAuthEntries: number
219+
readonly codexAuthEntries: number
218220
readonly geminiAuthEntries: number
219221
readonly grokAuthEntries: number
220222
}
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import type { PlatformError } from "@effect/platform/Error"
2+
import type * as FileSystem from "@effect/platform/FileSystem"
3+
import type * as Path from "@effect/platform/Path"
4+
import { Effect } from "effect"
5+
6+
import { hasGrokAuthJsonCredentialText, hasGrokUserSettingsCredentialText } from "@effect-template/lib/usecases/auth-grok-credential-text"
7+
8+
type HasCredentials = (
9+
fs: FileSystem.FileSystem,
10+
accountPath: string
11+
) => Effect.Effect<boolean, PlatformError>
12+
13+
const ignoredAuthAccountEntries: ReadonlySet<string> = new Set([".image"])
14+
const grokEnvApiKeyNames: ReadonlyArray<string> = ["GROK_DEPLOYMENT_KEY", "GROK_API_KEY", "XAI_API_KEY"]
15+
16+
const hasFileAtPath = (
17+
fs: FileSystem.FileSystem,
18+
filePath: string
19+
): Effect.Effect<boolean, PlatformError> =>
20+
Effect.gen(function*(_) {
21+
const exists = yield* _(fs.exists(filePath))
22+
if (!exists) {
23+
return false
24+
}
25+
const info = yield* _(fs.stat(filePath))
26+
return info.type === "File"
27+
})
28+
29+
const hasNonEmptyFile = (
30+
fs: FileSystem.FileSystem,
31+
filePath: string
32+
): Effect.Effect<boolean, PlatformError> =>
33+
Effect.gen(function*(_) {
34+
const hasFile = yield* _(hasFileAtPath(fs, filePath))
35+
if (!hasFile) {
36+
return false
37+
}
38+
const content = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => ""))
39+
return content.trim().length > 0
40+
})
41+
42+
const hasApiKeyInEnvFile = (
43+
fs: FileSystem.FileSystem,
44+
envFilePath: string,
45+
key: string
46+
): Effect.Effect<boolean, PlatformError> =>
47+
Effect.gen(function*(_) {
48+
const hasFile = yield* _(hasFileAtPath(fs, envFilePath))
49+
if (!hasFile) {
50+
return false
51+
}
52+
const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => ""))
53+
const prefix = `${key}=`
54+
for (const line of envContent.split("\n")) {
55+
const trimmed = line.trim()
56+
if (!trimmed.startsWith(prefix)) {
57+
continue
58+
}
59+
const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim()
60+
if (value.length > 0) {
61+
return true
62+
}
63+
}
64+
return false
65+
})
66+
67+
const hasAnyFile = (
68+
fs: FileSystem.FileSystem,
69+
basePath: string,
70+
fileNames: ReadonlyArray<string>
71+
): Effect.Effect<boolean, PlatformError> =>
72+
Effect.gen(function*(_) {
73+
for (const fileName of fileNames) {
74+
const hasFile = yield* _(hasFileAtPath(fs, `${basePath}/${fileName}`))
75+
if (hasFile) {
76+
return true
77+
}
78+
}
79+
return false
80+
})
81+
82+
const hasLegacyClaudeAuthFile = (
83+
fs: FileSystem.FileSystem,
84+
accountPath: string
85+
): Effect.Effect<boolean, PlatformError> =>
86+
Effect.gen(function*(_) {
87+
const exists = yield* _(fs.exists(accountPath))
88+
if (!exists) {
89+
return false
90+
}
91+
const entries = yield* _(fs.readDirectory(accountPath))
92+
for (const entry of entries) {
93+
if (!entry.startsWith(".claude") || !entry.endsWith(".json")) {
94+
continue
95+
}
96+
const isFile = yield* _(hasFileAtPath(fs, `${accountPath}/${entry}`))
97+
if (isFile) {
98+
return true
99+
}
100+
}
101+
return false
102+
})
103+
104+
export const hasClaudeAccountCredentials = (
105+
fs: FileSystem.FileSystem,
106+
accountPath: string
107+
): Effect.Effect<boolean, PlatformError> =>
108+
hasFileAtPath(fs, `${accountPath}/.credentials.json`).pipe(
109+
Effect.flatMap((hasCredentialsFile) => {
110+
if (hasCredentialsFile) {
111+
return Effect.succeed(true)
112+
}
113+
return hasFileAtPath(fs, `${accountPath}/.claude/.credentials.json`)
114+
}),
115+
Effect.flatMap((hasNestedCredentialsFile) => {
116+
if (hasNestedCredentialsFile) {
117+
return Effect.succeed(true)
118+
}
119+
return hasFileAtPath(fs, `${accountPath}/.config.json`)
120+
}),
121+
Effect.flatMap((hasConfig) => {
122+
if (hasConfig) {
123+
return Effect.succeed(true)
124+
}
125+
return hasNonEmptyFile(fs, `${accountPath}/.oauth-token`).pipe(
126+
Effect.flatMap((hasOauthToken) => hasOauthToken ? Effect.succeed(true) : hasLegacyClaudeAuthFile(fs, accountPath))
127+
)
128+
})
129+
)
130+
131+
export const hasGeminiAccountCredentials = (
132+
fs: FileSystem.FileSystem,
133+
accountPath: string
134+
): Effect.Effect<boolean, PlatformError> =>
135+
hasNonEmptyFile(fs, `${accountPath}/.api-key`).pipe(
136+
Effect.flatMap((hasApiKey) => {
137+
if (hasApiKey) {
138+
return Effect.succeed(true)
139+
}
140+
return hasApiKeyInEnvFile(fs, `${accountPath}/.env`, "GEMINI_API_KEY").pipe(
141+
Effect.flatMap((hasEnvApiKey) =>
142+
hasEnvApiKey
143+
? Effect.succeed(true)
144+
: hasAnyFile(fs, `${accountPath}/.gemini`, [
145+
"oauth_creds.json",
146+
"oauth-tokens.json",
147+
"credentials.json",
148+
"application_default_credentials.json"
149+
])
150+
)
151+
)
152+
})
153+
)
154+
155+
const hasGrokUserSettingsCredentials = (
156+
fs: FileSystem.FileSystem,
157+
settingsPath: string
158+
): Effect.Effect<boolean, PlatformError> =>
159+
Effect.gen(function*(_) {
160+
const hasFile = yield* _(hasFileAtPath(fs, settingsPath))
161+
if (!hasFile) {
162+
return false
163+
}
164+
const settingsText = yield* _(fs.readFileString(settingsPath), Effect.orElseSucceed(() => ""))
165+
return hasGrokUserSettingsCredentialText(settingsText)
166+
})
167+
168+
const hasGrokAuthJsonCredentials = (
169+
fs: FileSystem.FileSystem,
170+
authJsonPath: string
171+
): Effect.Effect<boolean, PlatformError> =>
172+
Effect.gen(function*(_) {
173+
const hasFile = yield* _(hasFileAtPath(fs, authJsonPath))
174+
if (!hasFile) {
175+
return false
176+
}
177+
const authJsonText = yield* _(fs.readFileString(authJsonPath), Effect.orElseSucceed(() => ""))
178+
return hasGrokAuthJsonCredentialText(authJsonText)
179+
})
180+
181+
const hasGrokEnvApiKey = (
182+
fs: FileSystem.FileSystem,
183+
envFilePath: string
184+
): Effect.Effect<boolean, PlatformError> =>
185+
Effect.gen(function*(_) {
186+
for (const key of grokEnvApiKeyNames) {
187+
const hasApiKey = yield* _(hasApiKeyInEnvFile(fs, envFilePath, key))
188+
if (hasApiKey) {
189+
return true
190+
}
191+
}
192+
return false
193+
})
194+
195+
export const hasGrokAccountCredentials = (
196+
fs: FileSystem.FileSystem,
197+
accountPath: string
198+
): Effect.Effect<boolean, PlatformError> =>
199+
hasNonEmptyFile(fs, `${accountPath}/.api-key`).pipe(
200+
Effect.flatMap((hasApiKey) => {
201+
if (hasApiKey) {
202+
return Effect.succeed(true)
203+
}
204+
return hasGrokEnvApiKey(fs, `${accountPath}/.env`).pipe(
205+
Effect.flatMap((hasEnvApiKey) => {
206+
if (hasEnvApiKey) {
207+
return Effect.succeed(true)
208+
}
209+
return hasGrokAuthJsonCredentials(fs, `${accountPath}/.grok/auth.json`).pipe(
210+
Effect.flatMap((hasAuthJson) =>
211+
hasAuthJson
212+
? Effect.succeed(true)
213+
: hasGrokUserSettingsCredentials(fs, `${accountPath}/.grok/user-settings.json`)
214+
)
215+
)
216+
})
217+
)
218+
})
219+
)
220+
221+
export const hasCodexAccountCredentials = (
222+
fs: FileSystem.FileSystem,
223+
accountPath: string
224+
): Effect.Effect<boolean, PlatformError> =>
225+
hasNonEmptyFile(fs, `${accountPath}/auth.json`)
226+
227+
export const countCodexCredentialAccounts = (
228+
fs: FileSystem.FileSystem,
229+
path: Path.Path,
230+
root: string
231+
): Effect.Effect<number, PlatformError> =>
232+
Effect.gen(function*(_) {
233+
const exists = yield* _(fs.exists(root))
234+
if (!exists) {
235+
return 0
236+
}
237+
238+
let count = yield* _(hasCodexAccountCredentials(fs, root), Effect.map((connected) => connected ? 1 : 0))
239+
const entries = yield* _(fs.readDirectory(root))
240+
for (const entry of entries) {
241+
if (ignoredAuthAccountEntries.has(entry)) {
242+
continue
243+
}
244+
245+
const accountPath = path.join(root, entry)
246+
const info = yield* _(fs.stat(accountPath))
247+
if (info.type !== "Directory") {
248+
continue
249+
}
250+
251+
const connected = yield* _(hasCodexAccountCredentials(fs, accountPath), Effect.orElseSucceed(() => false))
252+
if (connected) {
253+
count += 1
254+
}
255+
}
256+
return count
257+
})
258+
259+
export const countAuthCredentialAccounts = (
260+
fs: FileSystem.FileSystem,
261+
path: Path.Path,
262+
root: string,
263+
hasCredentials: HasCredentials
264+
): Effect.Effect<number, PlatformError> =>
265+
Effect.gen(function*(_) {
266+
const exists = yield* _(fs.exists(root))
267+
if (!exists) {
268+
return 0
269+
}
270+
271+
const entries = yield* _(fs.readDirectory(root))
272+
let count = 0
273+
for (const entry of entries) {
274+
if (ignoredAuthAccountEntries.has(entry)) {
275+
continue
276+
}
277+
278+
const accountPath = path.join(root, entry)
279+
const info = yield* _(fs.stat(accountPath))
280+
if (info.type !== "Directory") {
281+
continue
282+
}
283+
284+
const connected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false))
285+
if (connected) {
286+
count += 1
287+
}
288+
}
289+
return count
290+
})

packages/api/src/services/auth-menu.ts

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ import { Effect, pipe } from "effect"
1111

1212
import type { AuthMenuRequest, AuthSnapshot } from "../api/contracts.js"
1313
import { ApiBadRequestError } from "../api/errors.js"
14+
import {
15+
countAuthCredentialAccounts,
16+
countCodexCredentialAccounts,
17+
hasClaudeAccountCredentials,
18+
hasGeminiAccountCredentials,
19+
hasGrokAccountCredentials
20+
} from "./auth-account-counts.js"
1421

1522
type MenuAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
1623

1724
const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude`
25+
const codexAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/codex`
1826
const geminiAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/gemini`
1927
const grokAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/grok`
2028
const globalEnvPath = `${defaultProjectsRoot(process.cwd())}/.orch/env/global.env`
@@ -52,34 +60,6 @@ const countKeyEntries = (envText: string, baseKey: string): number => {
5260
.length
5361
}
5462

55-
const countAuthAccountDirectories = (
56-
fs: FileSystem.FileSystem,
57-
path: Path.Path,
58-
root: string
59-
): Effect.Effect<number, PlatformError> =>
60-
Effect.gen(function*(_) {
61-
const exists = yield* _(fs.exists(root))
62-
if (!exists) {
63-
return 0
64-
}
65-
66-
const entries = yield* _(fs.readDirectory(root))
67-
let count = 0
68-
for (const entry of entries) {
69-
if (entry === ".image") {
70-
continue
71-
}
72-
73-
const fullPath = path.join(root, entry)
74-
const info = yield* _(fs.stat(fullPath))
75-
if (info.type === "Directory") {
76-
count += 1
77-
}
78-
}
79-
80-
return count
81-
})
82-
8363
const loadAuthEnvText = (): Effect.Effect<
8464
{
8565
readonly fs: FileSystem.FileSystem
@@ -102,20 +82,23 @@ export const readAuthMenuSnapshot = (): Effect.Effect<AuthSnapshot, PlatformErro
10282
loadAuthEnvText(),
10383
Effect.flatMap(({ envText, fs, path }) =>
10484
Effect.all({
105-
claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot),
106-
geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot),
107-
grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot)
85+
claudeAuthEntries: countAuthCredentialAccounts(fs, path, claudeAuthRoot, hasClaudeAccountCredentials),
86+
codexAuthEntries: countCodexCredentialAccounts(fs, path, codexAuthRoot),
87+
geminiAuthEntries: countAuthCredentialAccounts(fs, path, geminiAuthRoot, hasGeminiAccountCredentials),
88+
grokAuthEntries: countAuthCredentialAccounts(fs, path, grokAuthRoot, hasGrokAccountCredentials)
10889
}).pipe(
109-
Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({
90+
Effect.map(({ claudeAuthEntries, codexAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({
11091
globalEnvPath,
11192
claudeAuthPath: claudeAuthRoot,
93+
codexAuthPath: codexAuthRoot,
11294
geminiAuthPath: geminiAuthRoot,
11395
grokAuthPath: grokAuthRoot,
11496
totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
11597
githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
11698
gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
11799
gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
118100
claudeAuthEntries,
101+
codexAuthEntries,
119102
geminiAuthEntries,
120103
grokAuthEntries
121104
}))

0 commit comments

Comments
 (0)