Skip to content

Commit 0f46e95

Browse files
authored
feat(auth): unify controller oauth flows (#333)
* feat(auth): unify controller oauth flows * fix(auth): satisfy ci lint checks * fix(auth): address review feedback * docs(auth): document grok oauth flow * docs(auth): clarify grok oauth docs * docs(auth): simplify auth quickstart * docs(auth): keep auth quickstart concise
1 parent b4ba7d9 commit 0f46e95

62 files changed

Lines changed: 2043 additions & 476 deletions

Some content is hidden

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

README.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ docker-git auth claude login --web
2828
docker-git auth grok login --web
2929
```
3030

31-
Grok support uses the official xAI CLI installer from `https://x.ai/cli/install.sh`
32-
and the CLI device-code login flow. API-key auth can also be stored under the
33-
selected Grok account label via `GROK_DEPLOYMENT_KEY`, `GROK_API_KEY`, or
34-
`XAI_API_KEY`.
35-
3631
## CLI пример
3732

3833
Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.

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

0 commit comments

Comments
 (0)