Skip to content

Commit e555e6d

Browse files
konardclaude
andcommitted
feat(auth): add Gemini CLI OAuth authentication support
Add OAuth login flow for Gemini CLI in headless/Docker environments: - Users can now authenticate via "Gemini CLI: login via OAuth (Google account)" - OAuth flow runs Gemini CLI with NO_BROWSER=true inside Docker container - User visits auth URL, copies authorization code, and pastes it back - Credentials stored in account-specific .gemini directory This addresses user feedback about needing to paste OAuth callback URLs when browser redirect to localhost doesn't work in Docker. Fixes #146 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5d3ef75 commit e555e6d

7 files changed

Lines changed: 539 additions & 65 deletions

File tree

packages/app/src/docker-git/menu-auth-data.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ const authMenuItems: ReadonlyArray<AuthMenuItem> = [
3434
{ action: "GitRemove", label: "Git: remove credentials" },
3535
{ action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
3636
{ action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
37+
{ action: "GeminiOauth", label: "Gemini CLI: login via OAuth (Google account)" },
3738
{ action: "GeminiApiKey", label: "Gemini CLI: set API key" },
38-
{ action: "GeminiLogout", label: "Gemini CLI: logout (clear API key)" },
39+
{ action: "GeminiLogout", label: "Gemini CLI: logout (clear credentials)" },
3940
{ action: "Refresh", label: "Refresh snapshot" },
4041
{ action: "Back", label: "Back to main menu" }
4142
]
@@ -61,6 +62,9 @@ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
6162
ClaudeLogout: [
6263
{ key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
6364
],
65+
GeminiOauth: [
66+
{ key: "label", label: "Label (empty = default)", required: false, secret: false }
67+
],
6468
GeminiApiKey: [
6569
{ key: "label", label: "Label (empty = default)", required: false, secret: false },
6670
{ key: "apiKey", label: "Gemini API key (from ai.google.dev)", required: true, secret: true }
@@ -78,6 +82,7 @@ const flowTitle = (flow: AuthFlow): string =>
7882
Match.when("GitRemove", () => "Git remove"),
7983
Match.when("ClaudeOauth", () => "Claude Code OAuth"),
8084
Match.when("ClaudeLogout", () => "Claude Code logout"),
85+
Match.when("GeminiOauth", () => "Gemini CLI OAuth"),
8186
Match.when("GeminiApiKey", () => "Gemini CLI API key"),
8287
Match.when("GeminiLogout", () => "Gemini CLI logout"),
8388
Match.exhaustive
@@ -91,6 +96,7 @@ export const successMessage = (flow: AuthFlow, label: string): string =>
9196
Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
9297
Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
9398
Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
99+
Match.when("GeminiOauth", () => `Saved Gemini CLI OAuth login (${label}).`),
94100
Match.when("GeminiApiKey", () => `Saved Gemini API key (${label}).`),
95101
Match.when("GeminiLogout", () => `Logged out Gemini CLI (${label}).`),
96102
Match.exhaustive

packages/app/src/docker-git/menu-auth-effects.ts

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
authClaudeLogin,
55
authClaudeLogout,
66
authGeminiLogin,
7+
authGeminiLoginOauth,
78
authGeminiLogout,
89
authGithubLogin,
910
claudeAuthRoot,
@@ -30,47 +31,43 @@ const resolveLabelOption = (values: Readonly<Record<string, string>>): string |
3031
return labelValue.length > 0 ? labelValue : null
3132
}
3233

34+
const resolveGithubOauthEffect = (labelOption: string | null, globalEnvPath: string) =>
35+
authGithubLogin({
36+
_tag: "AuthGithubLogin",
37+
label: labelOption,
38+
token: null,
39+
scopes: null,
40+
envGlobalPath: globalEnvPath
41+
})
42+
43+
const resolveClaudeOauthEffect = (labelOption: string | null) =>
44+
authClaudeLogin({ _tag: "AuthClaudeLogin", label: labelOption, claudeAuthPath: claudeAuthRoot })
45+
46+
const resolveClaudeLogoutEffect = (labelOption: string | null) =>
47+
authClaudeLogout({ _tag: "AuthClaudeLogout", label: labelOption, claudeAuthPath: claudeAuthRoot })
48+
49+
const resolveGeminiOauthEffect = (labelOption: string | null) =>
50+
authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot })
51+
52+
const resolveGeminiApiKeyEffect = (labelOption: string | null, apiKey: string) =>
53+
authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot }, apiKey)
54+
55+
const resolveGeminiLogoutEffect = (labelOption: string | null) =>
56+
authGeminiLogout({ _tag: "AuthGeminiLogout", label: labelOption, geminiAuthPath: geminiAuthRoot })
57+
3358
export const resolveAuthPromptEffect = (
3459
view: AuthPromptView,
3560
cwd: string,
3661
values: Readonly<Record<string, string>>
3762
): Effect.Effect<void, AppError, MenuEnv> => {
3863
const labelOption = resolveLabelOption(values)
3964
return Match.value(view.flow).pipe(
40-
Match.when("GithubOauth", () =>
41-
authGithubLogin({
42-
_tag: "AuthGithubLogin",
43-
label: labelOption,
44-
token: null,
45-
scopes: null,
46-
envGlobalPath: view.snapshot.globalEnvPath
47-
})),
48-
Match.when("ClaudeOauth", () =>
49-
authClaudeLogin({
50-
_tag: "AuthClaudeLogin",
51-
label: labelOption,
52-
claudeAuthPath: claudeAuthRoot
53-
})),
54-
Match.when("ClaudeLogout", () =>
55-
authClaudeLogout({
56-
_tag: "AuthClaudeLogout",
57-
label: labelOption,
58-
claudeAuthPath: claudeAuthRoot
59-
})),
60-
Match.when("GeminiApiKey", () => {
61-
const apiKey = (values["apiKey"] ?? "").trim()
62-
return authGeminiLogin({
63-
_tag: "AuthGeminiLogin",
64-
label: labelOption,
65-
geminiAuthPath: geminiAuthRoot
66-
}, apiKey)
67-
}),
68-
Match.when("GeminiLogout", () =>
69-
authGeminiLogout({
70-
_tag: "AuthGeminiLogout",
71-
label: labelOption,
72-
geminiAuthPath: geminiAuthRoot
73-
})),
65+
Match.when("GithubOauth", () => resolveGithubOauthEffect(labelOption, view.snapshot.globalEnvPath)),
66+
Match.when("ClaudeOauth", () => resolveClaudeOauthEffect(labelOption)),
67+
Match.when("ClaudeLogout", () => resolveClaudeLogoutEffect(labelOption)),
68+
Match.when("GeminiOauth", () => resolveGeminiOauthEffect(labelOption)),
69+
Match.when("GeminiApiKey", () => resolveGeminiApiKeyEffect(labelOption, (values["apiKey"] ?? "").trim())),
70+
Match.when("GeminiLogout", () => resolveGeminiLogoutEffect(labelOption)),
7471
Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)),
7572
Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)),
7673
Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)),

packages/app/src/docker-git/menu-auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => {
100100
const label = defaultLabel(nextValues["label"] ?? "")
101101
const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues)
102102
runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, {
103-
suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
103+
suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" ||
104+
view.flow === "GeminiOauth"
104105
})
105106
}
106107
)

packages/app/src/docker-git/menu-project-auth-gemini.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@ import { Effect } from "effect"
55
import { hasFileAtPath } from "./menu-project-auth-helpers.js"
66

77
// CHANGE: add Gemini CLI account credentials check for project auth
8-
// WHY: enable Gemini CLI authentication verification at project level
9-
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
10-
// REF: issue-146
8+
// WHY: enable Gemini CLI authentication verification at project level (API key or OAuth)
9+
// QUOTE(ТЗ): "Добавь поддержку gemini CLI", "Типо ждал пока мы вставим ссылку"
10+
// REF: issue-146, PR-147 comment from skulidropek
1111
// SOURCE: https://geminicli.com/docs/get-started/authentication/
1212
// FORMAT THEOREM: forall accountPath: hasGeminiAccountCredentials(fs, accountPath) = boolean | PlatformError
1313
// PURITY: SHELL
1414
// EFFECT: Effect<boolean, PlatformError>
15-
// INVARIANT: returns true only if valid API key exists
15+
// INVARIANT: returns true only if valid API key or OAuth credentials exist
1616
// COMPLEXITY: O(1)
1717

1818
const apiKeyFileName = ".api-key"
1919
const envFileName = ".env"
20+
const geminiCredentialsDir = ".gemini"
2021

2122
const hasNonEmptyApiKey = (
2223
fs: FileSystem.FileSystem,
@@ -54,6 +55,39 @@ const hasApiKeyInEnvFile = (
5455
return false
5556
})
5657

58+
// CHANGE: check for OAuth credentials in .gemini directory
59+
// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow
60+
// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку"
61+
// REF: issue-146, PR-147 comment
62+
// FORMAT THEOREM: hasOauthCredentials(fs, accountPath) -> boolean
63+
// PURITY: SHELL
64+
// INVARIANT: checks for existence of OAuth credential files
65+
// COMPLEXITY: O(1)
66+
const hasOauthCredentials = (
67+
fs: FileSystem.FileSystem,
68+
accountPath: string
69+
): Effect.Effect<boolean, PlatformError> =>
70+
Effect.gen(function*(_) {
71+
const credentialsDir = `${accountPath}/${geminiCredentialsDir}`
72+
const dirExists = yield* _(hasFileAtPath(fs, credentialsDir))
73+
if (!dirExists) {
74+
return false
75+
}
76+
// Check for various possible credential files Gemini CLI might create
77+
const possibleFiles = [
78+
`${credentialsDir}/oauth-tokens.json`,
79+
`${credentialsDir}/credentials.json`,
80+
`${credentialsDir}/application_default_credentials.json`
81+
]
82+
for (const filePath of possibleFiles) {
83+
const fileExists = yield* _(hasFileAtPath(fs, filePath))
84+
if (fileExists) {
85+
return true
86+
}
87+
}
88+
return false
89+
})
90+
5791
export const hasGeminiAccountCredentials = (
5892
fs: FileSystem.FileSystem,
5993
accountPath: string
@@ -63,6 +97,13 @@ export const hasGeminiAccountCredentials = (
6397
if (hasApiKey) {
6498
return Effect.succeed(true)
6599
}
66-
return hasApiKeyInEnvFile(fs, `${accountPath}/${envFileName}`)
100+
return hasApiKeyInEnvFile(fs, `${accountPath}/${envFileName}`).pipe(
101+
Effect.flatMap((hasEnvApiKey) => {
102+
if (hasEnvApiKey) {
103+
return Effect.succeed(true)
104+
}
105+
return hasOauthCredentials(fs, accountPath)
106+
})
107+
)
67108
})
68109
)

packages/app/src/docker-git/menu-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export type AuthFlow =
8484
| "GitRemove"
8585
| "ClaudeOauth"
8686
| "ClaudeLogout"
87+
| "GeminiOauth"
8788
| "GeminiApiKey"
8889
| "GeminiLogout"
8990

0 commit comments

Comments
 (0)