feat: support OAuth subscription login as alternative to API keys#193
feat: support OAuth subscription login as alternative to API keys#193benvinegar merged 5 commits intomodem-dev:mainfrom
Conversation
Add support for authenticating with LLM providers via OAuth subscription (e.g. ChatGPT Plus/Pro, Claude Pro/Max) instead of requiring API keys. ## Changes ### New: bin/oauth-login.mjs Standalone OAuth login script that implements the PKCE flows for OpenAI Codex and Anthropic. Writes credentials to pi's auth.json format. Supports both browser callback (localhost:1455) and manual URL paste for headless servers. ### New: baudbot login CLI command (sudo baudbot login) that runs the OAuth flow and writes auth.json with correct ownership/permissions for the agent user. ### config.sh — Two-tier auth picker The LLM config section now asks 'API key' vs 'Subscription login': - API key: existing flow (pick provider, enter key) - Subscription: pick Codex or Anthropic, run OAuth inline ### start.sh — auth.json fallback After checking API key env vars, start.sh now checks auth.json for OAuth credentials before failing. Supports openai-codex, anthropic, google, and github-copilot provider entries. ### doctor.sh — auth.json awareness The LLM health check now recognizes OAuth credentials in auth.json as valid, instead of reporting a false failure. ### install.sh — launch gate The post-install launch check now considers auth.json credentials when deciding whether the agent has valid LLM auth. Closes the workflow gap where headless/VM installs required manual patching of start.sh to use subscription-based LLM access.
Greptile SummaryThis PR adds OAuth subscription login as an alternative to API keys, enabling users with ChatGPT Plus/Pro or Claude Pro/Max subscriptions to authenticate without needing API keys. The implementation adds a new Key changes:
Critical issues:
Positive aspects:
Confidence Score: 2/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
Start([User runs baudbot config]) --> AuthTier{Choose auth tier}
AuthTier -->|API key| ProviderPicker[Pick LLM provider]
AuthTier -->|Subscription OAuth| CheckExisting{Existing auth.json?}
ProviderPicker --> EnterKey[Enter API key]
EnterKey --> ValidateKey{Key valid?}
ValidateKey -->|No| Fail([Exit with error])
ValidateKey -->|Yes| WriteEnv[Write to .env]
CheckExisting -->|Yes| Reauth{Re-authenticate?}
CheckExisting -->|No| RunOAuth[Run oauth-login.mjs]
Reauth -->|No| KeepCreds[Keep existing credentials]
Reauth -->|Yes| RunOAuth
RunOAuth --> PickOAuthProvider[Pick provider:<br/>OpenAI Codex or Anthropic]
PickOAuthProvider --> OpenURL[Open OAuth URL in browser]
OpenURL --> Callback{Callback method?}
Callback -->|localhost:1455| AutoCallback[Automatic callback]
Callback -->|Manual paste| PasteURL[Paste redirect URL/code]
AutoCallback --> ExchangeToken[Exchange code for tokens]
PasteURL --> ExchangeToken
ExchangeToken --> WriteAuth[Write to auth.json]
WriteAuth --> FixOwnership[Fix file ownership]
FixOwnership --> Done
KeepCreds --> Done
WriteEnv --> Done([Continue to Slack setup])
Last reviewed commit: 00fe358 |
bin/oauth-login.mjs
Outdated
|
|
||
| if (typeof serverCode === "string" && serverCode.startsWith("manual:")) { | ||
| const parsed = parseAuthorizationInput(serverCode.slice(7)); | ||
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); |
There was a problem hiding this comment.
state validation skipped when parsed.state is undefined, allowing CSRF attacks
The condition only throws when state exists AND mismatches. If user pastes just the code (no state param), validation is bypassed entirely. PKCE's state parameter must always be validated when received.
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); | |
| if (!parsed.state || parsed.state !== state) throw new Error("State mismatch"); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 229
Comment:
state validation skipped when `parsed.state` is undefined, allowing CSRF attacks
The condition only throws when state exists AND mismatches. If user pastes just the code (no state param), validation is bypassed entirely. PKCE's state parameter must always be validated when received.
```suggestion
if (!parsed.state || parsed.state !== state) throw new Error("State mismatch");
```
How can I resolve this? If you propose a fix, please make it concise.
bin/oauth-login.mjs
Outdated
| const remaining = await (typeof serverCode === "string" ? server.waitForCode() : manualPromise); | ||
| if (typeof remaining === "string" && remaining.trim()) { | ||
| const parsed = parseAuthorizationInput(remaining); | ||
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); |
There was a problem hiding this comment.
state validation skipped when parsed.state is undefined
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); | |
| if (!parsed.state || parsed.state !== state) throw new Error("State mismatch"); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 240
Comment:
state validation skipped when `parsed.state` is undefined
```suggestion
if (!parsed.state || parsed.state !== state) throw new Error("State mismatch");
```
How can I resolve this? If you propose a fix, please make it concise.
bin/oauth-login.mjs
Outdated
| // Final fallback prompt | ||
| const input = await ask(rl, " Paste the authorization code (or full redirect URL): "); | ||
| const parsed = parseAuthorizationInput(input); | ||
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); |
There was a problem hiding this comment.
state validation skipped when parsed.state is undefined
| if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); | |
| if (!parsed.state || parsed.state !== state) throw new Error("State mismatch"); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 251
Comment:
state validation skipped when `parsed.state` is undefined
```suggestion
if (!parsed.state || parsed.state !== state) throw new Error("State mismatch");
```
How can I resolve this? If you propose a fix, please make it concise.
bin/oauth-login.mjs
Outdated
| name: "Anthropic (Claude Pro/Max)", | ||
| authorizeUrl: "https://claude.ai/oauth/authorize", | ||
| tokenUrl: "https://console.anthropic.com/v1/oauth/token", | ||
| clientId: atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"), |
There was a problem hiding this comment.
base64 encoding provides no security for client secrets in source code
If this client ID needs protection, it should be externalized. If it's public (like OpenAI's on line 41), no obfuscation is needed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 51
Comment:
base64 encoding provides no security for client secrets in source code
If this client ID needs protection, it should be externalized. If it's public (like OpenAI's on line 41), no obfuscation is needed.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
This is a public OAuth client ID (same one shipped in Claude Code and pi) — the base64 is just to suppress secret-scanner false positives. Added a comment clarifying that.
bin/oauth-login.mjs
Outdated
| return { | ||
| access: tokens.access_token, | ||
| refresh: tokens.refresh_token, | ||
| expires: Date.now() + tokens.expires_in * 1000 - 5 * 60 * 1000, |
There was a problem hiding this comment.
5-minute buffer applied to Anthropic tokens but not OpenAI (line 290) - inconsistent without comment explaining why
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 353
Comment:
5-minute buffer applied to Anthropic tokens but not OpenAI (line 290) - inconsistent without comment explaining why
How can I resolve this? If you propose a fix, please make it concise.| # Test 1: Advanced Slack path writes socket-mode keys only | ||
| # Input: 1=API key tier, 1=Anthropic, key, 2=advanced Slack, tokens, ... | ||
| HOME1="$TMPDIR/advanced" | ||
| run_config "$HOME1" '1\nsk-ant-test\n2\nxoxb-test\nxapp-test\n\nn\nn\n' | ||
| run_config "$HOME1" '1\n1\nsk-ant-test\n2\nxoxb-test\nxapp-test\n\nn\nn\n' | ||
| ENV1="$HOME1/.baudbot/.env" | ||
| expect_file_contains "advanced path writes Anthropic key" "$ENV1" "ANTHROPIC_API_KEY=sk-ant-test" | ||
| expect_file_contains "advanced path writes SLACK_BOT_TOKEN" "$ENV1" "SLACK_BOT_TOKEN=xoxb-test" | ||
| expect_file_contains "advanced path writes SLACK_APP_TOKEN" "$ENV1" "SLACK_APP_TOKEN=xapp-test" | ||
| expect_file_not_contains "advanced path does not write OPENAI key" "$ENV1" "OPENAI_API_KEY=" | ||
|
|
||
| # Test 2: Easy Slack path avoids socket-mode keys |
There was a problem hiding this comment.
no test coverage for OAuth subscription login path (choice 2)
All tests select "1" (API key tier) then provider. The new OAuth subscription path needs at least one test to verify the flow works and auth.json is properly created/detected.
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/config.test.sh
Line: 85-95
Comment:
no test coverage for OAuth subscription login path (choice 2)
All tests select "1" (API key tier) then provider. The new OAuth subscription path needs at least one test to verify the flow works and `auth.json` is properly created/detected.
How can I resolve this? If you propose a fix, please make it concise.Replace Promise.race/fallback chain with straightforward sequential logic: prompt blocks until user pastes a URL or presses Enter, then either extract the code from input or await the server callback. Extract shared extractCode() helper to deduplicate state validation across all code acquisition paths.
Pi uses the stored expires timestamp to decide when to refresh. Without a buffer, tokens can expire between the check and the API call. Anthropic already had the buffer; apply consistently to OpenAI as well.
Add inline comment explaining the atob() wrapping is only to prevent secret-scanner false positives — this is a public OAuth client ID.
Test that selecting the subscription tier with pre-existing auth.json credentials skips the OAuth flow, omits API key env vars, and shows the subscription tier in the summary output.
|
@greptileai review |
| url.searchParams.set("scope", provider.scope); | ||
| url.searchParams.set("code_challenge", challenge); | ||
| url.searchParams.set("code_challenge_method", "S256"); | ||
| url.searchParams.set("state", verifier); |
There was a problem hiding this comment.
PKCE verifier should not be used as OAuth state parameter
State is for CSRF protection (opaque to auth server), while verifier is secret for PKCE binding. Using the same value weakens both mechanisms. Generate a separate random value:
| url.searchParams.set("state", verifier); | |
| const state = randomBytes(16).toString("hex"); |
Then add before line 286:
const state = randomBytes(16).toString("hex");
And validate it after line 308.
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 293
Comment:
PKCE verifier should not be used as OAuth state parameter
State is for CSRF protection (opaque to auth server), while verifier is secret for PKCE binding. Using the same value weakens both mechanisms. Generate a separate random value:
```suggestion
const state = randomBytes(16).toString("hex");
```
Then add before line 286:
```
const state = randomBytes(16).toString("hex");
```
And validate it after line 308.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
This mirrors pi's upstream Anthropic OAuth implementation exactly — the verifier-as-state pattern is how pi itself handles this flow. If it changes upstream we'll pick it up.
| const splits = input.trim().split("#"); | ||
| const code = splits[0]; | ||
| const state = splits[1]; | ||
|
|
There was a problem hiding this comment.
state extracted but never validated - CSRF vulnerability
The state parameter is extracted from user input but never validated against the expected value (verifier or separate random state). Add validation:
const splits = input.trim().split("#");
const code = splits[0];
const state = splits[1];
if (!state || state !== verifier) {
throw new Error("State mismatch - possible CSRF attack");
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: bin/oauth-login.mjs
Line: 306-309
Comment:
state extracted but never validated - CSRF vulnerability
The state parameter is extracted from user input but never validated against the expected value (verifier or separate random state). Add validation:
```
const splits = input.trim().split("#");
const code = splits[0];
const state = splits[1];
if (!state || state !== verifier) {
throw new Error("State mismatch - possible CSRF attack");
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Same as above — this matches pi's upstream Anthropic OAuth flow, which passes state through to the token exchange for server-side validation rather than client-side.
Summary
Add support for authenticating with LLM providers via OAuth subscription (e.g. ChatGPT Plus/Pro, Claude Pro/Max) instead of requiring API keys. This closes the workflow gap where headless/VM installs required manual patching of
start.shto use subscription-based LLM access.Motivation
Pi (the underlying agent harness) supports OAuth subscription login via
/login, but baudbot's setup and startup scripts only check for API key environment variables. Users with subscriptions but no API keys are blocked — they can't get pastbaudbot configorstart.shwithout workarounds.This came up during a Proxmox VM install where the only viable LLM auth was a ChatGPT Pro subscription. The workaround required manually running pi to do
/login, then patchingstart.shto checkauth.json— a patch that gets overwritten on everybaudbot update.Changes
New:
bin/oauth-login.mjsStandalone OAuth login script implementing PKCE flows for OpenAI Codex and Anthropic. Writes credentials to pi's
auth.jsonformat. Supports both localhost callback (port 1455) and manual URL paste for headless servers.New:
baudbot loginCLI command (
sudo baudbot login) that runs the OAuth flow and writesauth.jsonwith correct ownership/permissions for the agent user. Can be run standalone or re-run later to re-authenticate.config.sh— Two-tier auth pickerThe LLM configuration now presents a first-level choice:
start.sh—auth.jsonfallbackAfter checking API key env vars,
start.shnow checks~/.pi/agent/auth.jsonfor OAuth credentials before failing. Supportsopenai-codex,anthropic,google, andgithub-copilotprovider entries.doctor.sh—auth.jsonawarenessThe LLM health check recognizes OAuth credentials in
auth.jsonas valid, instead of reporting a false failure.install.sh— launch gateThe post-install launch check now considers
auth.jsoncredentials when deciding whether the agent has valid LLM auth.Testing
config.test.shtests updated for the new auth tier choice and passing (15/15)baudbot.test.shCLI tests passing (5/5).mjsfile