Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 <key>`.');
});

it('should throw on network errors after retries', async () => {
Expand Down
202 changes: 194 additions & 8 deletions src/__tests__/cli.internal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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();
});
});
Loading
Loading