feat: add --no-browser OAuth mode for headless servers

## Feature Description

Enables adding Google accounts on remote servers without a desktop
environment (headless Linux, Docker containers, SSH sessions) where
automatic browser opening is not possible. Users can manually copy the
authorization URL to a device with a browser, complete authentication,
and paste the authorization code back.

## Usage

npm run accounts:add -- --no-browser

## Code Architecture

### New Modules

1. oauth.js - extractCodeFromInput()
   - Parses user input (full callback URL or raw authorization code)
   - Extracts code and state parameters
   - Handles OAuth error responses

2. accounts.js - addAccountNoBrowser()
   - Account addition flow for no-browser mode
   - Displays authorization URL for manual copying
   - Waits for user to paste authorization code
   - Calls extractCodeFromInput to parse input
   - Completes OAuth flow and saves account

3. tests/test-oauth-no-browser.cjs
   - 13 unit tests covering valid URLs, raw codes, error handling, edge cases

### Modified Modules

1. accounts.js - interactiveAdd()
   - Added noBrowser parameter
   - Selects addAccount or addAccountNoBrowser based on mode

2. accounts.js - main()
   - Parses --no-browser CLI argument
   - Updated help information

## User Flow

┌─────────────────────────────────────────────────────────────┐
│                   Headless Server Terminal                   │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
            ┌─────────────────────────────────┐
            │ npm run accounts:add -- --no-browser │
            └─────────────────────────────────┘
                              │
                              ▼
            ┌─────────────────────────────────┐
            │   Display Google OAuth URL       │
            │   (manual copy required)         │
            └─────────────────────────────────┘
                              │
         ┌────────────────────┴────────────────────┐
         │                                         │
         ▼                                         ▼
┌─────────────────┐                    ┌─────────────────────┐
│  Local Browser  │                    │  Phone/Other Device │
│  Open URL       │                    │  Open URL           │
│  Google Sign-in │                    │  Google Sign-in     │
│  Authorize App  │                    │  Authorize App      │
└─────────────────┘                    └─────────────────────┘
         │                                         │
         └────────────────────┬────────────────────┘
                              │
                              ▼
            ┌─────────────────────────────────┐
            │ Browser redirects to localhost   │
            │ (page won't load - this is OK)   │
            │ Copy full URL or code parameter  │
            └─────────────────────────────────┘
                              │
                              ▼
            ┌─────────────────────────────────┐
            │ Return to server terminal        │
            │ Paste URL or authorization code  │
            └─────────────────────────────────┘
                              │
                              ▼
            ┌─────────────────────────────────┐
            │ ✓ Account added successfully     │
            └─────────────────────────────────┘

## Security Considerations

- Supports state parameter validation (when user pastes full URL)
- Warns on state mismatch but allows continuation (manual mode tolerance)
- Authorization code length validation to prevent incorrect input

## Compatibility

- Does not affect existing automatic browser OAuth flow
- All changes are additive, no modifications to upstream logic
- Easy to merge future upstream updates
This commit is contained in:
Caixiaopig
2026-01-04 16:53:30 +08:00
committed by Badri Narayanan S
parent d03c79cc39
commit 573ba57db6
3 changed files with 343 additions and 4 deletions

View File

@@ -57,6 +57,56 @@ export function getAuthorizationUrl() {
};
}
/**
* Extract authorization code and state from user input.
* User can paste either:
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
* - Just the code parameter: 4/0xxx...
*
* @param {string} input - User input (URL or code)
* @returns {{code: string, state: string|null}} Extracted code and optional state
*/
export function extractCodeFromInput(input) {
if (!input || typeof input !== 'string') {
throw new Error('No input provided');
}
const trimmed = input.trim();
// Check if it looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const url = new URL(trimmed);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code) {
throw new Error('No authorization code found in URL');
}
return { code, state };
} catch (e) {
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
throw e;
}
throw new Error('Invalid URL format');
}
}
// Assume it's a raw code
// Google auth codes typically start with "4/" and are long
if (trimmed.length < 10) {
throw new Error('Input is too short to be a valid authorization code');
}
return { code: trimmed, state: null };
}
/**
* Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code
@@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) {
export default {
getAuthorizationUrl,
extractCodeFromInput,
startCallbackServer,
exchangeCode,
refreshAccessToken,

View File

@@ -25,7 +25,8 @@ import {
startCallbackServer,
completeOAuthFlow,
refreshAccessToken,
getUserEmail
getUserEmail,
extractCodeFromInput
} from '../auth/oauth.js';
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
@@ -229,6 +230,63 @@ async function addAccount(existingAccounts) {
}
}
/**
* Add a new account via OAuth with manual code input (no-browser mode)
* For headless servers without a desktop environment
*/
async function addAccountNoBrowser(existingAccounts, rl) {
console.log('\n=== Add Google Account (No-Browser Mode) ===\n');
// Generate authorization URL
const { url, verifier, state } = getAuthorizationUrl();
console.log('Copy the following URL and open it in a browser on another device:\n');
console.log(` ${url}\n`);
console.log('After signing in, you will be redirected to a localhost URL.');
console.log('Copy the ENTIRE redirect URL or just the authorization code.\n');
const input = await rl.question('Paste the callback URL or authorization code: ');
try {
const { code, extractedState } = extractCodeFromInput(input);
// Validate state if present
if (extractedState && extractedState !== state) {
console.log('\n⚠ State mismatch detected. This could indicate a security issue.');
console.log('Proceeding anyway as this is manual mode...');
}
console.log('\nExchanging authorization code for tokens...');
const result = await completeOAuthFlow(code, verifier);
// Check if account already exists
const existing = existingAccounts.find(a => a.email === result.email);
if (existing) {
console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`);
existing.refreshToken = result.refreshToken;
existing.projectId = result.projectId;
existing.addedAt = new Date().toISOString();
return null; // Don't add duplicate
}
console.log(`\n✓ Successfully authenticated: ${result.email}`);
if (result.projectId) {
console.log(` Project ID: ${result.projectId}`);
}
return {
email: result.email,
refreshToken: result.refreshToken,
projectId: result.projectId,
addedAt: new Date().toISOString(),
modelRateLimits: {}
};
} catch (error) {
console.error(`\n✗ Authentication failed: ${error.message}`);
return null;
}
}
/**
* Interactive remove accounts flow
*/
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
/**
* Interactive add accounts flow (Main Menu)
* @param {Object} rl - readline interface
* @param {boolean} noBrowser - if true, use manual code input mode
*/
async function interactiveAdd(rl) {
async function interactiveAdd(rl, noBrowser = false) {
if (noBrowser) {
console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n');
}
const accounts = loadAccounts();
if (accounts.length > 0) {
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
return;
}
const newAccount = await addAccount(accounts);
// Use appropriate add function based on mode
const newAccount = noBrowser
? await addAccountNoBrowser(accounts, rl)
: await addAccount(accounts);
if (newAccount) {
accounts.push(newAccount);
saveAccounts(accounts);
@@ -388,6 +456,7 @@ async function verifyAccounts() {
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'add';
const noBrowser = args.includes('--no-browser');
console.log('╔════════════════════════════════════════╗');
console.log('║ Antigravity Proxy Account Manager ║');
@@ -399,7 +468,7 @@ async function main() {
switch (command) {
case 'add':
await ensureServerStopped();
await interactiveAdd(rl);
await interactiveAdd(rl, noBrowser);
break;
case 'list':
await listAccounts();
@@ -418,6 +487,8 @@ async function main() {
console.log(' node src/cli/accounts.js verify Verify account tokens');
console.log(' node src/cli/accounts.js clear Remove all accounts');
console.log(' node src/cli/accounts.js help Show this help');
console.log('\nOptions:');
console.log(' --no-browser Manual authorization code input (for headless servers)');
break;
case 'remove':
await ensureServerStopped();