diff --git a/CHANGELOG.md b/CHANGELOG.md index eaff1c64..820b0440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ### Minor Changes +- Add RFC 8628 OAuth Device Authorization Grant as the new default login flow. + + - `nansen login` opens a browser, displays a user code, and polls until approved — no manual key copy-paste. + - `nansen login --api-key ` continues to work unchanged for CI and scripting. + - API calls transparently refresh OAuth tokens 60 s before expiry via `_ensureFreshToken()`. + - `nansen logout` clears OAuth tokens as well as legacy API keys. + - `DEFAULT_AUTH_BASE_URL` exported from `api.js` containing the superapp auth URL. + - new config `authBaseUrl` added, defaulting to `DEFAULT_AUTH_BASE_URL`. + - `Authorization: Bearer` header used for OAuth sessions; `apikey` header used for legacy API key sessions (both coexist cleanly). + - [#279](https://github.com/nansen-ai/nansen-cli/pull/279) [`174a3d6`](https://github.com/nansen-ai/nansen-cli/commit/174a3d612b3f198c5e5b979c269d896f9d906704) Thanks [@kome12](https://github.com/kome12)! - Add `nansen account` command to verify API key and check credit balance Users can now run `nansen account` to confirm their API key is valid and see diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js index 92300834..f6a80d5e 100644 --- a/src/__tests__/api.test.js +++ b/src/__tests__/api.test.js @@ -1785,7 +1785,7 @@ describe('NansenAPI', () => { mockFetch.mockResolvedValueOnce(errorResponse); - await expect(api.smartMoneyNetflow({})).rejects.toThrow('Invalid API key'); + await expect(api.smartMoneyNetflow({})).rejects.toThrow('Authentication failed. Run `nansen login` to re-authenticate.'); }); it('should show login guidance for 401 when no API key', async () => { @@ -1802,7 +1802,7 @@ describe('NansenAPI', () => { mockFetch.mockResolvedValueOnce(errorResponse); - await expect(apiNoKey.smartMoneyNetflow({})).rejects.toThrow('Not logged in. Run: nansen login'); + await expect(apiNoKey.smartMoneyNetflow({})).rejects.toThrow('Not logged in. Run `nansen login` (OAuth) or `nansen login --api-key `.'); }); it('should throw on network errors after retries', async () => { diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 6d2b5e50..c0dad65e 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -5,6 +5,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Prevent the OAuth device flow from actually opening a browser window. +// vi.mock is hoisted, so it intercepts the dynamic import('child_process') in cli.js. +vi.mock('child_process', () => ({ exec: vi.fn(), default: { exec: vi.fn() } })); import { parseArgs, formatValue, @@ -997,12 +1001,32 @@ describe('buildCommands', () => { }); describe('login command', () => { - it('should exit when no API key provided', async () => { + it('should start OAuth device flow and save tokens when no API key', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300 + })}); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600 + })}); + })); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await commands.login([], null, {}, {}); + + stdoutSpy.mockRestore(); + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; - expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(mockDeps.saveConfigFn).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + apiKey: undefined, + })); }); it('should exit when API key is whitespace', async () => { @@ -1011,6 +1035,7 @@ describe('buildCommands', () => { await commands.login([], null, {}, { 'api-key': ' ' }); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(logs.some(l => l.includes('INVALID_API_KEY'))).toBe(true); }); it('should save config with --api-key option after verification', async () => { @@ -1026,15 +1051,17 @@ describe('buildCommands', () => { }); }); - it('should exit when no API key available', async () => { + it('should report DEVICE_AUTHORIZE_FAILED when auth endpoint returns 403', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })); await commands.login([], null, {}, {}); + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; expect(mockDeps.exit).toHaveBeenCalledWith(1); - expect(logs.some(l => l.includes('API_KEY_REQUIRED'))).toBe(true); + expect(logs.some(l => l.includes('DEVICE_AUTHORIZE_FAILED'))).toBe(true); }); it('should reject invalid API key (401)', async () => { @@ -1817,15 +1844,17 @@ describe('login/logout flow', () => { expect(logs.some(l => l.includes('Saved to'))).toBe(true); }); - it('should exit when no API key available', async () => { + it('should report DEVICE_AUTHORIZE_FAILED when auth endpoint returns 403', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; - + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })); + await commands.login([], null, {}, {}); - + + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; - expect(logs.some(l => l.includes('API_KEY_REQUIRED'))).toBe(true); expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(logs.some(l => l.includes('DEVICE_AUTHORIZE_FAILED'))).toBe(true); }); }); @@ -3462,3 +3491,160 @@ describe('buildPagination', () => { expect(buildPagination({ limit: 25 })).toEqual({ page: 1, per_page: 25 }); }); }); + +// =================== OAuth Device Flow =================== + +import * as childProcess from 'child_process'; + +describe('OAuth device flow', () => { + let mockDeps; + let commands; + let logs; + + beforeEach(() => { + logs = []; + mockDeps = { + log: (msg) => logs.push(msg), + exit: vi.fn(), + promptFn: vi.fn(), + saveConfigFn: vi.fn(), + deleteConfigFn: vi.fn(), + getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), + NansenAPIClass: vi.fn(), + isTTY: false, + }; + commands = buildCommands(mockDeps); + vi.mocked(childProcess.exec).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeFetchMock({ authFails = false, pendingCount = 0 } = {}) { + let pollCalls = 0; + return vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + if (authFails) return Promise.resolve({ ok: false, status: 403 }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300, + })}); + } + pollCalls++; + if (pollCalls <= pendingCount) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ error: 'authorization_pending' }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600, + })}); + }); + } + + it('should use DEFAULT_AUTH_BASE_URL when config has no authBaseUrl', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + mockDeps.loadConfigFn = () => ({ baseUrl: 'https://api.nansen.ai' }); + commands = buildCommands(mockDeps); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + const authorizeCall = fetchMock.mock.calls.find(([url]) => url.includes('/authorize')); + expect(authorizeCall[0]).toContain('https://app.nansen.ai'); + }); + + it('should use authBaseUrl from config for device flow', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + mockDeps.loadConfigFn = () => ({ authBaseUrl: 'https://staging.app.nansen.ai', baseUrl: 'https://staging.api.nansen.ai' }); + commands = buildCommands(mockDeps); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + const authorizeCall = fetchMock.mock.calls.find(([url]) => url.includes('/authorize')); + expect(authorizeCall[0]).toContain('https://staging.app.nansen.ai'); + }); + + it('should continue polling when HTTP 201 body contains authorization_pending', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock({ pendingCount: 2 })); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(mockDeps.saveConfigFn).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'test-access-token', + })); + }); + + it('should print ! to stdout on network error during polling', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + let pollCalls = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300, + })}); + } + pollCalls++; + if (pollCalls === 1) throw new Error('Network error'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600, + })}); + })); + mockDeps.isTTY = true; + commands = buildCommands(mockDeps); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await commands.login([], null, {}, {}); + + const wroteExclamation = stdoutSpy.mock.calls.some(([c]) => c === '!'); + stdoutSpy.mockRestore(); + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(wroteExclamation).toBe(true); + }); + + it('should attempt to open browser on authorize success', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock()); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(childProcess.exec).toHaveBeenCalledOnce(); + expect(vi.mocked(childProcess.exec).mock.calls[0][0]).toContain('https://app.nansen.ai/device'); + }); + + it('should log fallback message when browser launch throws', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock()); + vi.mocked(childProcess.exec).mockImplementation(() => { throw new Error('no browser'); }); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(logs.some(l => l.includes('Could not open browser automatically'))).toBe(true); + }); + + it('should not attempt browser launch when authorize fails', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock({ authFails: true })); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(childProcess.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api.js b/src/api.js index 8b3816d9..75827ae4 100644 --- a/src/api.js +++ b/src/api.js @@ -164,6 +164,8 @@ export function deleteConfig() { return false; } +export const DEFAULT_AUTH_BASE_URL = 'https://app.nansen.ai'; + // ============= Response Cache ============= const CACHE_DIR = path.join(CONFIG_DIR, 'cache'); @@ -300,7 +302,7 @@ export function validateTokenAddress(tokenAddress, chain = 'solana') { return validateAddress(tokenAddress, chain); } -function loadConfig() { +export function loadConfig() { // Base config from files, then env vars override individual fields let config = null; @@ -318,13 +320,16 @@ function loadConfig() { } if (!config) { - config = { apiKey: null, baseUrl: 'https://api.nansen.ai' }; + config = { apiKey: null, baseUrl: 'https://api.nansen.ai', authBaseUrl: DEFAULT_AUTH_BASE_URL, accessToken: null, refreshToken: null, tokenExpiry: 0 }; } - // Ensure baseUrl default (config file from older versions may omit it) + // Ensure defaults (config file from older versions may omit these) if (!config.baseUrl) { config.baseUrl = 'https://api.nansen.ai'; } + if (!config.authBaseUrl) { + config.authBaseUrl = DEFAULT_AUTH_BASE_URL; + } // Env vars override individual fields if (process.env.NANSEN_API_KEY) { @@ -333,6 +338,9 @@ function loadConfig() { if (process.env.NANSEN_BASE_URL) { config.baseUrl = process.env.NANSEN_BASE_URL; } + if (process.env.NANSEN_AUTH_URL) { + config.authBaseUrl = process.env.NANSEN_AUTH_URL; + } return config; } @@ -407,6 +415,10 @@ export class NansenAPI { constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) { this.apiKey = apiKey || null; this.baseUrl = baseUrl; + this.authBaseUrl = config.authBaseUrl || DEFAULT_AUTH_BASE_URL; + this.accessToken = config.accessToken || null; + this.refreshToken = config.refreshToken || null; + this.tokenExpiry = config.tokenExpiry || 0; this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry }; this.cacheOptions = { enabled: options.cache?.enabled ?? false, @@ -415,6 +427,44 @@ export class NansenAPI { this.defaultHeaders = options.defaultHeaders || {}; } + /** + * Refresh the OAuth access token if it is about to expire (within 60s). + * Clears credentials and throws NansenError if the refresh fails. + */ + async _ensureFreshToken() { + if (!this.accessToken) return; + if (this.tokenExpiry > Date.now() + 60_000) return; + + const res = await fetch(`${this.authBaseUrl}/api/auth/device/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: this.refreshToken }) + }); + + if (!res.ok) { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = 0; + throw new NansenError( + 'Session expired. Run `nansen login` to re-authenticate.', + ErrorCode.UNAUTHORIZED, + 401 + ); + } + + const data = await res.json(); + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token; + this.tokenExpiry = Date.now() + (data.expires_in * 1000); + + saveConfig({ + ...loadConfig(), + accessToken: this.accessToken, + refreshToken: this.refreshToken, + tokenExpiry: this.tokenExpiry + }); + } + static cleanBody(body) { return Object.fromEntries( Object.entries(body).filter(([_, v]) => @@ -472,10 +522,12 @@ export class NansenAPI { } async request(endpoint, body = {}, options = {}) { + await this._ensureFreshToken(); + const url = `${this.baseUrl}${endpoint}`; const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions; const shouldRetry = options.retry !== false; // Allow disabling retry per-request - + // Check cache first (if enabled and not bypassed) const useCache = options.cache !== false && this.cacheOptions.enabled; const cacheTtl = options.cacheTtl ?? this.cacheOptions.ttl; @@ -501,7 +553,9 @@ export class NansenAPI { 'X-Client-Type': 'nansen-cli', 'X-Client-Version': packageVersion, ...telemetryHeaders(), - ...(this.apiKey ? { 'apikey': this.apiKey } : {}), + ...(this.accessToken + ? { 'Authorization': `Bearer ${this.accessToken}` } + : this.apiKey ? { 'apikey': this.apiKey } : {}), ...this.defaultHeaders, ...options.headers }, @@ -558,7 +612,9 @@ export class NansenAPI { // Enhance messages for specific error codes if (code === ErrorCode.UNAUTHORIZED) { - message = this.apiKey ? message : 'Not logged in. Run: nansen login'; + message = (this.accessToken || this.apiKey) + ? 'Authentication failed. Run `nansen login` to re-authenticate.' + : 'Not logged in. Run `nansen login` (OAuth) or `nansen login --api-key `.'; } else if (code === ErrorCode.UNSUPPORTED_FILTER) { message = message.replace(/\.+$/, '') + '. This filter is not supported for this token/chain combination. Do not retry.'; } else if (code === ErrorCode.CREDITS_EXHAUSTED) { diff --git a/src/cli.js b/src/cli.js index 53993d8a..df9abd4d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ * Extracted from index.js for coverage */ -import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep } from './api.js'; +import { NansenAPI, NansenError, ErrorCode, loadConfig, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep, DEFAULT_AUTH_BASE_URL } from './api.js'; import { buildWalletCommands } from './wallet.js'; import { buildTradingCommands } from './trading.js'; import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js'; @@ -684,7 +684,7 @@ COMMANDS: wallet create, list, show, export, default, delete, forget-password alerts list, create, update, toggle, delete account Show API key status, plan, and remaining credits - login Save API key (--api-key or NANSEN_API_KEY env var) + login Authenticate via browser (OAuth) or --api-key for legacy key logout Remove saved API key schema JSON schema for all commands (use "nansen schema " for one) cache clear @@ -765,6 +765,7 @@ export function buildCommands(deps = {}) { log = console.log, errorOutput: _errorOutput = console.error, NansenAPIClass: _NansenAPIClass = NansenAPI, + loadConfigFn = loadConfig, saveConfigFn = saveConfig, deleteConfigFn = deleteConfig, getConfigFileFn = getConfigFile, @@ -779,14 +780,15 @@ export function buildCommands(deps = {}) { 'login': async (args, apiInstance, flags, options) => { if (flags.help || flags.h) { - log('nansen login - Save your Nansen API key\n'); + log('nansen login - Authenticate with Nansen\n'); log('USAGE:'); - log(' nansen login --api-key '); - log(' NANSEN_API_KEY= nansen login'); - log(' nansen login --human (interactive prompt)\n'); + log(' nansen login (OAuth device flow — opens browser)'); + log(' nansen login --api-key (legacy API key)'); + log(' NANSEN_API_KEY= nansen login (legacy API key via env)'); + log(' nansen login --human (interactive prompt for API key)\n'); log('OPTIONS:'); - log(' --api-key Your Nansen API key'); - log(' --human Enable interactive prompt'); + log(' --api-key Your Nansen API key (legacy)'); + log(' --human Enable interactive prompt for API key'); log(' --help Show this help\n'); log('Get your API key at: https://app.nansen.ai/api'); return; @@ -812,69 +814,178 @@ export function buildCommands(deps = {}) { apiKey = await promptFn('Enter your API key: ', true); } - if (!apiKey || apiKey.trim().length === 0) { - log(JSON.stringify({ - error: 'API_KEY_REQUIRED', - message: 'No API key provided.', - resolution: [ - 'Run: nansen login --api-key ', - 'Or set NANSEN_API_KEY environment variable', - 'Get your API key at: https://app.nansen.ai/api', - ], - })); + if (apiKey && apiKey.trim().length === 0) { + log(JSON.stringify({ error: 'INVALID_API_KEY', message: 'API key cannot be blank.' })); exit(1); return; } - // Verify API key before saving - const NansenAPIClass = _NansenAPIClass; - const testApi = new NansenAPIClass(apiKey.trim(), undefined, { - retry: { maxRetries: 2 }, - cache: { enabled: false } - }); + if (apiKey && apiKey.trim().length > 0) { + // Verify API key before saving + const NansenAPIClass = _NansenAPIClass; + const testApi = new NansenAPIClass(apiKey.trim(), undefined, { + retry: { maxRetries: 2 }, + cache: { enabled: false } + }); - let accountInfo; + let accountInfo; + try { + accountInfo = await testApi.getAccount(); + } catch (error) { + if (error.code === ErrorCode.UNAUTHORIZED) { + log(JSON.stringify({ + error: 'INVALID_API_KEY', + message: 'The API key is not valid.', + resolution: ['Check your key at https://app.nansen.ai/api'] + })); + } else { + log(JSON.stringify({ + error: 'VERIFICATION_FAILED', + message: `Could not verify API key: ${error.message}`, + resolution: ['Check your internet connection', 'Try again'] + })); + } + exit(1); + return; + } + + saveConfigFn({ + apiKey: apiKey.trim(), + baseUrl: 'https://api.nansen.ai' + }); + + log(`✓ Saved to ${getConfigFileFn()}\n`); + if (accountInfo?.plan) { + log(`Plan: ${accountInfo.plan}`); + } + if (accountInfo?.credits_remaining !== undefined) { + log(`Credits remaining: ${accountInfo.credits_remaining}`); + } + log('\nYou can now use the Nansen CLI. Try:'); + log(' nansen research token screener --chain solana --pretty'); + return; + } + + // Default: OAuth device flow + const currentConfig = loadConfigFn(); + const authBase = currentConfig.authBaseUrl || DEFAULT_AUTH_BASE_URL; + + // Step 1: initiate + let authorizeData; try { - accountInfo = await testApi.getAccount(); - } catch (error) { - if (error.code === ErrorCode.UNAUTHORIZED) { - log(JSON.stringify({ - error: 'INVALID_API_KEY', - message: 'The API key is not valid.', - resolution: ['Check your key at https://app.nansen.ai/api'] - })); - } else { - log(JSON.stringify({ - error: 'VERIFICATION_FAILED', - message: `Could not verify API key: ${error.message}`, - resolution: ['Check your internet connection', 'Try again'] - })); + const authorizeRes = await fetch(`${authBase}/api/auth/device/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (!authorizeRes.ok) throw new Error(`HTTP ${authorizeRes.status}`); + authorizeData = await authorizeRes.json(); + } catch (err) { + log(JSON.stringify({ error: 'DEVICE_AUTHORIZE_FAILED', message: `Failed to start device authorization: ${err.message}` })); + exit(1); + return; + } + + const { device_code, user_code, verification_uri, interval = 5, expires_in = 300 } = authorizeData; + + log('\nNansen CLI Login\n'); + log(`1. Open: ${verification_uri}`); + log(`2. Enter code: ${user_code}\n`); + + // Try to open browser (best effort, silent on failure) + try { + const { exec: execChild } = await import('child_process'); + const opener = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + execChild(`${opener} "${verification_uri}"`); + } catch (_) { + log('Could not open browser automatically. Please open the URL above manually.\n'); + } + + // Step 2: poll + let pollInterval = interval * 1000; + const deadline = Date.now() + expires_in * 1000; + let accessToken = null, refreshToken = null, tokenExpiry = null; + + if (isTTY) process.stdout.write('Waiting for authorization'); + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, pollInterval)); + if (isTTY) process.stdout.write('.'); + + let pollData; + try { + const pollRes = await fetch(`${authBase}/api/auth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code }) + }); + pollData = await pollRes.json(); + + if (pollRes.ok && !pollData.error) { + accessToken = pollData.access_token; + refreshToken = pollData.refresh_token; + tokenExpiry = Date.now() + (pollData.expires_in * 1000); + break; + } + } catch { + // network error — keep polling + if (isTTY) process.stdout.write('!'); + continue; } + + const errCode = pollData?.error; + if (errCode === 'slow_down') { + pollInterval += 5000; + } else if (errCode === 'access_denied') { + if (isTTY) process.stdout.write('\n'); + log(JSON.stringify({ error: 'ACCESS_DENIED', message: 'Authorization was denied.' })); + exit(1); + return; + } else if (errCode === 'expired_token') { + if (isTTY) process.stdout.write('\n'); + log(JSON.stringify({ error: 'EXPIRED', message: 'Code expired. Run `nansen login` again.' })); + exit(1); + return; + } + } + + if (isTTY) process.stdout.write('\n'); + + if (!accessToken) { + log(JSON.stringify({ error: 'TIMEOUT', message: 'Authorization timed out. Run `nansen login` again.' })); exit(1); return; } - // Key is valid - now save saveConfigFn({ - apiKey: apiKey.trim(), - baseUrl: 'https://api.nansen.ai' + ...currentConfig, + accessToken, + refreshToken, + tokenExpiry, + apiKey: undefined }); - log(`✓ Saved to ${getConfigFileFn()}\n`); - if (accountInfo?.plan) { - log(`Plan: ${accountInfo.plan}`); - } - if (accountInfo?.credits_remaining !== undefined) { - log(`Credits remaining: ${accountInfo.credits_remaining}`); + // Decode email from JWT payload (no verification needed here) + let email = 'your account'; + try { + const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString()); + email = payload.email || payload.sub || email; + } catch (_) { + /* JWT decode is best-effort; fall back to generic label */ + log('Could not decode user info') } + + log(`\n✓ Logged in as ${email}`); + log(`Saved to ${getConfigFileFn()}\n`); log('\nYou can now use the Nansen CLI. Try:'); log(' nansen research token screener --chain solana --pretty'); + return; }, 'logout': async (_args, _apiInstance, _flags, _options) => { const deleted = deleteConfigFn(); if (deleted) { - log(`✓ Removed ${getConfigFileFn()}`); + log(`✓ Removed ${getConfigFileFn()} (API key and OAuth tokens cleared)`); } else { log('No saved credentials found'); }