Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-cli-oauth-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"scope3": minor
---

Add `scope3 login` and `scope3 logout` commands for browser-based OAuth authentication via WorkOS. Credentials are saved to `~/.scope3/config.json` and used automatically for subsequent API calls.
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions src/__tests__/cli/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Tests for OAuth utility functions
*/

import { generateState } from '../../cli/oauth';

describe('generateState', () => {
it('returns a non-empty hex string', () => {
const state = generateState();
expect(state).toMatch(/^[0-9a-f]+$/);
});

it('returns 32 hex characters (16 bytes)', () => {
expect(generateState()).toHaveLength(32);
});

it('returns unique values', () => {
expect(generateState()).not.toBe(generateState());
});
});
94 changes: 93 additions & 1 deletion src/__tests__/cli/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getConfigForDisplay,
createClient,
parseJsonArg,
resolveBaseUrl,
} from '../../cli/utils';

// Mock fs
Expand All @@ -33,6 +34,13 @@ jest.mock('../../client', () => ({
})),
}));

// Mock getDefaultBaseUrl
jest.mock('../../adapters/base', () => ({
getDefaultBaseUrl: jest.fn((env: string) =>
env === 'staging' ? 'https://api.agentic.staging.scope3.com' : 'https://api.agentic.scope3.com'
),
}));

const CONFIG_DIR = path.join('/mock/home', '.scope3');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');

Expand Down Expand Up @@ -155,6 +163,17 @@ describe('getConfigForDisplay', () => {
expect(display.environment).toBe('staging');
expect(display.baseUrl).toBe('https://custom.com');
});

it('shows oauthToken as <saved> when present', () => {
const display = getConfigForDisplay({ oauthAccessToken: 'scope3_abc_xyz' });
expect(display.oauthToken).toBe('<saved>');
});

it('shows tokenExpiry as ISO string when present', () => {
const expiry = Math.floor(Date.now() / 1000) + 3600;
const display = getConfigForDisplay({ tokenExpiry: expiry });
expect(display.tokenExpiry).toBe(new Date(expiry * 1000).toISOString());
});
});

describe('createClient', () => {
Expand All @@ -171,7 +190,7 @@ describe('createClient', () => {

it('should throw when no API key is available', () => {
delete process.env.SCOPE3_API_KEY;
expect(() => createClient({})).toThrow('API key required');
expect(() => createClient({})).toThrow('Not authenticated');
});

it('should use CLI flag API key first', () => {
Expand All @@ -198,6 +217,79 @@ describe('createClient', () => {
const client = createClient({ apiKey: 'test-key' });
expect(client).toBeDefined();
});

it('should use OAuth access token when no API key is provided', () => {
delete process.env.SCOPE3_API_KEY;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(
JSON.stringify({ oauthAccessToken: 'eyJhbGci.test.token' })
);

const client = createClient({});
expect(client).toBeDefined();
});

it('should prefer CLI flag over OAuth token', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify({ oauthAccessToken: 'oauth-token' }));

const client = createClient({ apiKey: 'cli-key' });
expect(client).toBeDefined();
});

it('should error when both config apiKey and oauthAccessToken are set', () => {
delete process.env.SCOPE3_API_KEY;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(
JSON.stringify({ apiKey: 'config-key', oauthAccessToken: 'oauth-token' })
);

expect(() => createClient({})).toThrow('Both an API key and an OAuth session are configured.');
});

it('should use CLI flag when both config apiKey and oauthAccessToken are set', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(
JSON.stringify({ apiKey: 'config-key', oauthAccessToken: 'oauth-token' })
);

const client = createClient({ apiKey: 'cli-key' });
expect(client).toBeDefined();
});
});

describe('resolveBaseUrl', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(false);
});

it('defaults to production URL', () => {
expect(resolveBaseUrl()).toBe('https://api.agentic.scope3.com');
});

it('returns staging URL for staging environment', () => {
expect(resolveBaseUrl({ environment: 'staging' })).toBe(
'https://api.agentic.staging.scope3.com'
);
});

it('prefers explicit baseUrl over environment', () => {
expect(resolveBaseUrl({ baseUrl: 'https://custom.example.com' })).toBe(
'https://custom.example.com'
);
});

it('strips trailing slash from baseUrl', () => {
expect(resolveBaseUrl({ baseUrl: 'https://custom.example.com/' })).toBe(
'https://custom.example.com'
);
});

it('reads environment from config when not provided', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify({ environment: 'staging' }));
expect(resolveBaseUrl()).toBe('https://api.agentic.staging.scope3.com');
});
});

describe('parseJsonArg', () => {
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { creativeSetsCommand } from './creative-sets';
export { partnersCommand, agentsCommand } from './partners';
export { reportingCommand } from './reporting';
export { salesAgentsCommand } from './sales-agents';
export { loginCommand, logoutCommand } from './login';
127 changes: 127 additions & 0 deletions src/cli/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Login and logout commands
*/

import { Command } from 'commander';
import chalk from 'chalk';
import { generateState, openBrowser, waitForCallback, CALLBACK_PORT } from '../oauth';
import { loadConfig, saveConfig, resolveBaseUrl } from '../utils';

const PROVIDERS = ['google', 'github', 'microsoft'] as const;
type Provider = (typeof PROVIDERS)[number];

export const loginCommand = new Command('login')
.description('Log in to Scope3 via browser OAuth')
.option(
'--provider <provider>',
`OAuth provider: ${PROVIDERS.join(', ')} (default: google)`,
'google'
)
.option('--environment <env>', 'Environment: production or staging')
.option('--base-url <url>', 'Custom API base URL')
.action(async (options) => {
const provider = options.provider as Provider;

if (!PROVIDERS.includes(provider)) {
console.error(
chalk.red(`Invalid provider: ${provider}. Must be one of: ${PROVIDERS.join(', ')}`)
);
process.exit(1);
}

const baseUrl = resolveBaseUrl({
environment: options.environment,
baseUrl: options.baseUrl,
});

const state = generateState();
const redirectUri = `http://localhost:${CALLBACK_PORT}/callback`;
const authUrlParams = new URLSearchParams({ provider, redirect_uri: redirectUri, state });

let authorizationUrl: string;
try {
const response = await fetch(`${baseUrl}/auth/url?${authUrlParams}`);
if (!response.ok) {
const body = await response.text();
throw new Error(`${response.status}: ${body}`);
}
const data = (await response.json()) as { authorizationUrl: string };
authorizationUrl = data.authorizationUrl;
} catch (error) {
console.error(
chalk.red(
`Failed to get authorization URL: ${error instanceof Error ? error.message : 'Unknown error'}`
)
);
process.exit(1);
}

console.log(chalk.cyan('\nOpening browser for login...'));
console.log(chalk.gray(`If the browser does not open, visit:\n ${authorizationUrl}\n`));

try {
openBrowser(authorizationUrl);
} catch (error) {
console.error(chalk.red(error instanceof Error ? error.message : 'Failed to open browser'));
process.exit(1);
}

let code: string;
try {
const result = await waitForCallback(CALLBACK_PORT, state);
code = result.code;
} catch (error) {
console.error(chalk.red(error instanceof Error ? error.message : 'Login failed'));
process.exit(1);
}

let serviceToken: string;
let tokenExpiry: number;
try {
const response = await fetch(`${baseUrl}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`${response.status}: ${body}`);
}
const data = (await response.json()) as Record<string, unknown>;
const token = data.service_token || data.access_token;
if (!token || typeof token !== 'string' || token.trim() === '') {
throw new Error(`No token in response. Got keys: ${Object.keys(data).join(', ')}`);
}
serviceToken = token;
// Use server-provided expiry if available; default to 30 days for service tokens
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 30 * 24 * 60 * 60;
tokenExpiry = Math.floor(Date.now() / 1000) + expiresIn;
} catch (error) {
console.error(
chalk.red(
`Token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}`
)
);
process.exit(1);
}

const config = loadConfig();
saveConfig({
...config,
oauthAccessToken: serviceToken,
tokenExpiry,
});

console.log(chalk.green('Logged in successfully.'));
console.log(chalk.gray('Try: scope3 advertisers list'));
});

export const logoutCommand = new Command('logout')
.description('Log out and clear saved credentials')
.action(() => {
const config = loadConfig();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { oauthAccessToken, tokenExpiry, ...rest } = config;
saveConfig(rest);
console.log(chalk.green('Logged out.'));
});
23 changes: 23 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ import {
configCommand,
conversionEventsCommand,
creativeSetsCommand,
loginCommand,
logoutCommand,
partnersCommand,
reportingCommand,
salesAgentsCommand,
} from './commands';
import { loadConfig } from './utils';

const program = new Command();

Expand All @@ -49,7 +52,27 @@ program
.option('--debug', 'Enable debug mode')
.option('--persona <persona>', 'API persona: buyer or partner (default: buyer)');

// Warn if the OAuth session token is expired before running any command
program.hook('preAction', (_thisCommand, actionCommand) => {
const skipCommands = ['login', 'logout', 'config', 'commands'];
if (skipCommands.includes(actionCommand.name())) return;

// If an explicit key is provided, OAuth session state is irrelevant
if (_thisCommand.opts().apiKey || process.env.SCOPE3_API_KEY) return;

const config = loadConfig();
if (!config.oauthAccessToken || !config.tokenExpiry) return;

const now = Math.floor(Date.now() / 1000);
if (now >= config.tokenExpiry) {
console.error(chalk.yellow('Your session has expired. Run "scope3 login" to log in again.'));
process.exit(1);
}
});

// Add commands
program.addCommand(loginCommand);
program.addCommand(logoutCommand);
program.addCommand(advertisersCommand);
program.addCommand(bundlesCommand);
program.addCommand(campaignsCommand);
Expand Down
Loading