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:
committed by
Badri Narayanan S
parent
d03c79cc39
commit
573ba57db6
@@ -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
|
* Start a local server to receive the OAuth callback
|
||||||
* Returns a promise that resolves with the authorization code
|
* Returns a promise that resolves with the authorization code
|
||||||
@@ -338,6 +388,7 @@ export async function completeOAuthFlow(code, verifier) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
getAuthorizationUrl,
|
getAuthorizationUrl,
|
||||||
|
extractCodeFromInput,
|
||||||
startCallbackServer,
|
startCallbackServer,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
startCallbackServer,
|
startCallbackServer,
|
||||||
completeOAuthFlow,
|
completeOAuthFlow,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
getUserEmail
|
getUserEmail,
|
||||||
|
extractCodeFromInput
|
||||||
} from '../auth/oauth.js';
|
} from '../auth/oauth.js';
|
||||||
|
|
||||||
const SERVER_PORT = process.env.PORT || DEFAULT_PORT;
|
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
|
* Interactive remove accounts flow
|
||||||
*/
|
*/
|
||||||
@@ -275,8 +333,14 @@ async function interactiveRemove(rl) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive add accounts flow (Main Menu)
|
* 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();
|
const accounts = loadAccounts();
|
||||||
|
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
@@ -307,7 +371,11 @@ async function interactiveAdd(rl) {
|
|||||||
return;
|
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) {
|
if (newAccount) {
|
||||||
accounts.push(newAccount);
|
accounts.push(newAccount);
|
||||||
saveAccounts(accounts);
|
saveAccounts(accounts);
|
||||||
@@ -388,6 +456,7 @@ async function verifyAccounts() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const command = args[0] || 'add';
|
const command = args[0] || 'add';
|
||||||
|
const noBrowser = args.includes('--no-browser');
|
||||||
|
|
||||||
console.log('╔════════════════════════════════════════╗');
|
console.log('╔════════════════════════════════════════╗');
|
||||||
console.log('║ Antigravity Proxy Account Manager ║');
|
console.log('║ Antigravity Proxy Account Manager ║');
|
||||||
@@ -399,7 +468,7 @@ async function main() {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
case 'add':
|
case 'add':
|
||||||
await ensureServerStopped();
|
await ensureServerStopped();
|
||||||
await interactiveAdd(rl);
|
await interactiveAdd(rl, noBrowser);
|
||||||
break;
|
break;
|
||||||
case 'list':
|
case 'list':
|
||||||
await listAccounts();
|
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 verify Verify account tokens');
|
||||||
console.log(' node src/cli/accounts.js clear Remove all accounts');
|
console.log(' node src/cli/accounts.js clear Remove all accounts');
|
||||||
console.log(' node src/cli/accounts.js help Show this help');
|
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;
|
break;
|
||||||
case 'remove':
|
case 'remove':
|
||||||
await ensureServerStopped();
|
await ensureServerStopped();
|
||||||
|
|||||||
217
tests/test-oauth-no-browser.cjs
Normal file
217
tests/test-oauth-no-browser.cjs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* OAuth No-Browser Mode Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the extractCodeFromInput() function which enables OAuth authentication
|
||||||
|
* on headless servers without a desktop environment.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* FEATURE: --no-browser OAuth Mode
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* PURPOSE:
|
||||||
|
* Allow users to add Google accounts on remote servers (headless Linux,
|
||||||
|
* Docker containers, SSH sessions) where automatic browser opening is
|
||||||
|
* not possible.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* npm run accounts:add -- --no-browser
|
||||||
|
*
|
||||||
|
* USER FLOW:
|
||||||
|
* 1. User runs command on headless server
|
||||||
|
* 2. System displays Google OAuth URL
|
||||||
|
* 3. User opens URL on another device (phone/laptop) with a browser
|
||||||
|
* 4. User signs in to Google and authorizes the app
|
||||||
|
* 5. Browser redirects to localhost (page won't load - this is expected)
|
||||||
|
* 6. User copies the redirect URL or authorization code from address bar
|
||||||
|
* 7. User pastes into server terminal
|
||||||
|
* 8. System extracts code using extractCodeFromInput() (tested here)
|
||||||
|
* 9. Account is added successfully
|
||||||
|
*
|
||||||
|
* FUNCTION UNDER TEST:
|
||||||
|
* extractCodeFromInput(input: string) => { code: string, state: string|null }
|
||||||
|
*
|
||||||
|
* Accepts either:
|
||||||
|
* - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy
|
||||||
|
* - Raw authorization code: 4/0AQSTgQG...
|
||||||
|
*
|
||||||
|
* Throws on:
|
||||||
|
* - Empty/null input
|
||||||
|
* - Too short input (< 10 chars)
|
||||||
|
* - URL with OAuth error parameter
|
||||||
|
* - URL without code parameter
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Run: node tests/test-oauth-no-browser.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Note: Using dynamic import because oauth.js is ESM
|
||||||
|
async function runTests() {
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('OAUTH NO-BROWSER MODE UNIT TESTS');
|
||||||
|
console.log('Testing: extractCodeFromInput()');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Import the ESM module
|
||||||
|
const { extractCodeFromInput } = await import('../src/auth/oauth.js');
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to run a single test case
|
||||||
|
* @param {string} name - Test name
|
||||||
|
* @param {Function} testFn - Test function that returns { passed, message }
|
||||||
|
*/
|
||||||
|
async function test(name, testFn) {
|
||||||
|
try {
|
||||||
|
const { passed, message } = await testFn();
|
||||||
|
results.push({ name, passed, message });
|
||||||
|
const status = passed ? 'PASS' : 'FAIL';
|
||||||
|
console.log(` [${status}] ${name}`);
|
||||||
|
if (message) console.log(` ${message}`);
|
||||||
|
if (!passed) allPassed = false;
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ name, passed: false, message: error.message });
|
||||||
|
console.log(` [FAIL] ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Test Group 1: Valid URL Inputs =====
|
||||||
|
console.log('\n--- Valid URL Inputs ---');
|
||||||
|
|
||||||
|
await test('Parse full callback URL with code and state', () => {
|
||||||
|
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTg123' && result.state === 'abc123';
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse URL with only code (no state)', () => {
|
||||||
|
const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTg456' && result.state === null;
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse HTTPS URL', () => {
|
||||||
|
const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === 'secureCode123';
|
||||||
|
return { passed, message: `code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse URL with additional query params', () => {
|
||||||
|
const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === 'myCode' && result.state === 'myState';
|
||||||
|
return { passed, message: `code=${result.code}, state=${result.state}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 2: Raw Code Inputs =====
|
||||||
|
console.log('\n--- Raw Authorization Code Inputs ---');
|
||||||
|
|
||||||
|
await test('Parse raw authorization code (Google format)', () => {
|
||||||
|
const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === input && result.state === null;
|
||||||
|
return { passed, message: `code length=${result.code.length}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Parse raw code with whitespace (should trim)', () => {
|
||||||
|
const input = ' 4/0AQSTgQGcode123 \n';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === '4/0AQSTgQGcode123' && result.state === null;
|
||||||
|
return { passed, message: `trimmed code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 3: Error Cases =====
|
||||||
|
console.log('\n--- Error Handling ---');
|
||||||
|
|
||||||
|
await test('Throw on empty input', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No input'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on null input', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput(null);
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No input'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on too short code', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('abc');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('too short'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on OAuth error in URL', () => {
|
||||||
|
try {
|
||||||
|
const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied';
|
||||||
|
extractCodeFromInput(input);
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('OAuth error'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Throw on URL without code param', () => {
|
||||||
|
try {
|
||||||
|
extractCodeFromInput('http://localhost:51121/callback?state=onlyState');
|
||||||
|
return { passed: false, message: 'Should have thrown' };
|
||||||
|
} catch (e) {
|
||||||
|
return { passed: e.message.includes('No authorization code'), message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Test Group 4: Edge Cases =====
|
||||||
|
console.log('\n--- Edge Cases ---');
|
||||||
|
|
||||||
|
await test('Handle URL-encoded characters in code', () => {
|
||||||
|
const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
// URL class automatically decodes
|
||||||
|
const passed = result.code === '4/0AQSTg++';
|
||||||
|
return { passed, message: `decoded code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Accept minimum valid code length (10 chars)', () => {
|
||||||
|
const input = '1234567890';
|
||||||
|
const result = extractCodeFromInput(input);
|
||||||
|
const passed = result.code === input;
|
||||||
|
return { passed, message: `code=${result.code}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Summary =====
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('SUMMARY');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.passed).length;
|
||||||
|
const failed = results.filter(r => !r.passed).length;
|
||||||
|
console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
process.exit(allPassed ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch(err => {
|
||||||
|
console.error('Test suite failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user