Skip to content

feat: support OAuth subscription login as alternative to API keys#193

Merged
benvinegar merged 5 commits intomodem-dev:mainfrom
elucid:subscription-auth-support
Mar 2, 2026
Merged

feat: support OAuth subscription login as alternative to API keys#193
benvinegar merged 5 commits intomodem-dev:mainfrom
elucid:subscription-auth-support

Conversation

@elucid
Copy link
Member

@elucid elucid commented Mar 1, 2026

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.sh to 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 past baudbot config or start.sh without 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 patching start.sh to check auth.json — a patch that gets overwritten on every baudbot update.

Changes

New: bin/oauth-login.mjs

Standalone OAuth login script implementing PKCE flows for OpenAI Codex and Anthropic. Writes credentials to pi's auth.json format. Supports both localhost callback (port 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. Can be run standalone or re-run later to re-authenticate.

config.sh — Two-tier auth picker

The LLM configuration now presents a first-level choice:

  • API key → existing flow (pick provider, enter key)
  • Subscription login (OAuth) → pick ChatGPT Plus/Pro or Claude Pro/Max, complete OAuth inline

start.shauth.json fallback

After checking API key env vars, start.sh now checks ~/.pi/agent/auth.json for OAuth credentials before failing. Supports openai-codex, anthropic, google, and github-copilot provider entries.

doctor.shauth.json awareness

The LLM health check 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.

Testing

  • All existing config.test.sh tests updated for the new auth tier choice and passing (15/15)
  • All baudbot.test.sh CLI tests passing (5/5)
  • Syntax verified: all modified shell scripts and the new .mjs file
  • OAuth flow tested manually during original VM setup (documented in install notes)

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-apps
Copy link

greptile-apps bot commented Mar 1, 2026

Greptile Summary

This 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 baudbot login command and integrates OAuth flows into the existing configuration system.

Key changes:

  • New bin/oauth-login.mjs implements PKCE-based OAuth flows for OpenAI Codex and Anthropic providers
  • bin/config.sh now presents a two-tier authentication choice: API key or subscription login
  • start.sh, doctor.sh, and install.sh all recognize OAuth credentials in ~/.pi/agent/auth.json
  • Test suite updated to accommodate the new auth tier prompt

Critical issues:

  • The Anthropic OAuth flow has two security vulnerabilities: it uses the PKCE verifier as the state parameter (weakening both CSRF and PKCE protections), and it never validates the state parameter against the expected value when the user manually pastes the authorization code
  • These issues create a CSRF vulnerability that could allow an attacker to trick a user into authenticating with the attacker's account

Positive aspects:

  • Well-structured integration across all relevant scripts
  • Proper file permissions (0o600) and ownership handling for sensitive credential files
  • Graceful fallback between API keys and OAuth credentials
  • Good user experience with automatic callback server and manual paste fallback

Confidence Score: 2/5

  • This PR has critical security vulnerabilities in the Anthropic OAuth flow that must be fixed before merging
  • The Anthropic OAuth implementation has two critical security flaws: using the PKCE verifier as the state parameter (line 293) and missing state validation (lines 306-309). These vulnerabilities expose users to CSRF attacks during authentication. While the OpenAI Codex flow is more secure, and the rest of the implementation is well-structured with proper permissions and integration, the security issues in Anthropic OAuth are severe enough to warrant fixing before merge.
  • bin/oauth-login.mjs requires immediate security fixes for the Anthropic OAuth flow (lines 293 and 306-309)

Important Files Changed

Filename Overview
bin/oauth-login.mjs New OAuth login script with CSRF vulnerability in Anthropic flow - state parameter not validated
bin/config.sh Added two-tier auth picker (API key vs OAuth subscription) with proper error handling and Node.js resolution
bin/baudbot Added login command with proper ownership fixes for auth.json
start.sh Added auth.json fallback for OAuth credentials with proper jq dependency check
bin/config.test.sh Updated tests for new auth tier prompt, added Test 7 for subscription path but doesn't test actual OAuth flow

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])
Loading

Last reviewed commit: 00fe358

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 files reviewed, 8 comments

Edit Code Review Agent Settings | Greptile


if (typeof serverCode === "string" && serverCode.startsWith("manual:")) {
const parsed = parseAuthorizationInput(serverCode.slice(7));
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

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");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state validation skipped when parsed.state is undefined

Suggested change
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.

// 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");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state validation skipped when parsed.state is undefined

Suggested change
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.

name: "Anthropic (Claude Pro/Max)",
authorizeUrl: "https://claude.ai/oauth/authorize",
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
clientId: atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

return {
access: tokens.access_token,
refresh: tokens.refresh_token,
expires: Date.now() + tokens.expires_in * 1000 - 5 * 60 * 1000,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 85 to 95
# 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@elucid elucid marked this pull request as draft March 1, 2026 22:11
elucid added 4 commits March 1, 2026 17:25
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.
@elucid elucid marked this pull request as ready for review March 1, 2026 23:38
@elucid
Copy link
Member Author

elucid commented Mar 1, 2026

@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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +306 to +309
const splits = input.trim().split("#");
const code = splits[0];
const state = splits[1];

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@benvinegar benvinegar merged commit bb35c93 into modem-dev:main Mar 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants