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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 0.5.0 - 2026-08-02

### Added

- `anyapi login` now starts the RFC 8628 OAuth device flow when no manual key is
supplied. It prints the verification URL and user code, opens the complete URL
best-effort, follows the server polling interval and `slow_down`, and stores the
same access token, refresh token, expiry, and scope fields as `anyapi connect`.
Account sessions refresh automatically before the one-hour access token expires.

### Changed

- `anyapi login --api-key aa_live_...` remains the manual-key compatibility path
and clears any unrelated OAuth or trial state before selecting that key.
- `login` is the immediate cross-device account sign-in path; `connect` remains
the Authorization Code + PKCE loopback path for upgrading a free trial.

## 0.3.3

### Changed
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}'

- `anyapi signup [--label <label>] [--show-key]` - mint a free trial key and save it locally. The secret is not printed unless you pass `--show-key`.
- `anyapi connect` - upgrade past the free trial via a one-URL OAuth 2.1 approval (Authorization Code + PKCE over a loopback callback). Prints a single consent URL for a human to open; on approval the CLI stores the access token and keeps working.
- `anyapi login --api-key aa_live_...` - store an existing dashboard key locally.
- `anyapi login` - sign in to an AnyAPI account immediately with the OAuth 2.0 device flow. The CLI prints a verification URL and user code, opens the complete URL when possible, and waits without binding a localhost callback.
- `anyapi login --api-key aa_live_...` - manual-key compatibility path: store an existing dashboard key locally without starting OAuth.
- `anyapi search <query>` - search the public catalog and print SKU, name, and USD price terms.
- `anyapi list [--category <cat>]` - list catalog APIs.
- `anyapi describe <sku>` - print the authenticated API definition, including opaque schemas and gateway-published USD pricing, lane order, and failover metadata.
Expand All @@ -43,6 +44,10 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}'

Auth resolution order is `--api-key`, then `ANYAPI_API_KEY`, then `~/.anyapi/config.json`, then trial self-signup. When the trial budget is spent, runs return HTTP 402 `trial_cap_reached`; run `anyapi connect` to continue.

Use `login` for immediate account-backed OAuth on any device. Use `connect` to
upgrade and continue an existing free trial while preserving its trial receipt.
OAuth access tokens refresh automatically from the saved session before expiry.

## Gateway and CLI responsibilities

The AnyAPI gateway owns input validation, provider normalization, pricing, lane order,
Expand Down
125 changes: 124 additions & 1 deletion __tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveApiKey } from '../src/auth.js';
import { getConfigPath, writeConfig } from '../src/config.js';
import { getConfigPath, readConfig, writeConfig } from '../src/config.js';
import type { FetchLike } from '../src/types.js';

const METADATA_URL = 'https://api.getanyapi.com/.well-known/oauth-authorization-server';
const TOKEN_URL = 'https://auth.example.test/token';

const tempDirs: string[] = [];

Expand Down Expand Up @@ -42,6 +46,125 @@ describe('auth resolution', () => {
const auth = await resolveApiKey({ env: {} as NodeJS.ProcessEnv, configPath: getConfigPath(dir) });
expect(auth).toMatchObject({ source: 'missing' });
});

it('rotates an expiring OAuth access token before returning config auth', async () => {
const dir = await tempDir();
const configPath = getConfigPath(dir);
await writeConfig({
apiKey: 'aa_at_old',
refreshToken: 'aa_rt_old',
oauthClientId: 'aa_client_login',
accessTokenExpiresAt: '2026-08-02T12:00:30.000Z',
scope: 'run balance:read',
}, configPath);
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchImpl: FetchLike = async (input, init) => {
const url = String(input);
requests.push({ url, init });
if (url === METADATA_URL) {
return Response.json({
authorization_endpoint: 'https://auth.example.test/authorize',
token_endpoint: TOKEN_URL,
});
}
if (url === TOKEN_URL) {
return Response.json({
access_token: 'aa_at_new',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'aa_rt_new',
scope: 'run balance:read',
});
}
throw new Error(`Unexpected URL: ${url}`);
};

const auth = await resolveApiKey({
env: {} as NodeJS.ProcessEnv,
configPath,
fetchImpl,
now: () => Date.parse('2026-08-02T12:00:00.000Z'),
});

expect(auth.apiKey).toBe('aa_at_new');
expect(requests.map((request) => request.url)).toEqual([METADATA_URL, TOKEN_URL]);
expect(Object.fromEntries(new URLSearchParams(String(requests[1].init?.body)))).toEqual({
grant_type: 'refresh_token',
refresh_token: 'aa_rt_old',
client_id: 'aa_client_login',
});
expect(await readConfig(configPath)).toMatchObject({
apiKey: 'aa_at_new',
refreshToken: 'aa_rt_new',
oauthClientId: 'aa_client_login',
accessTokenExpiresAt: '2026-08-02T13:00:00.000Z',
});
});

it('does not refresh a config OAuth token outside the refresh window', async () => {
const dir = await tempDir();
const configPath = getConfigPath(dir);
await writeConfig({
apiKey: 'aa_at_current',
refreshToken: 'aa_rt_current',
oauthClientId: 'aa_client_login',
accessTokenExpiresAt: '2026-08-02T12:05:00.000Z',
}, configPath);

const auth = await resolveApiKey({
env: {} as NodeJS.ProcessEnv,
configPath,
fetchImpl: async () => { throw new Error('refresh should not run'); },
now: () => Date.parse('2026-08-02T12:00:00.000Z'),
});

expect(auth.apiKey).toBe('aa_at_current');
});

it.each([
[{ clientId: 'aa_client_trial', cliClientId: 'aa_client_cli' }, 'aa_client_trial'],
[{ cliClientId: 'aa_client_cli' }, 'aa_client_cli'],
])('migrates legacy OAuth client provenance before refreshing', async (legacyClients, expectedClientId) => {
const dir = await tempDir();
const configPath = getConfigPath(dir);
await writeConfig({
apiKey: 'aa_at_old',
refreshToken: 'aa_rt_old',
accessTokenExpiresAt: '2026-08-02T12:00:00.000Z',
...legacyClients,
}, configPath);
let refreshClientId: string | undefined;
const fetchImpl: FetchLike = async (input, init) => {
const url = String(input);
if (url === METADATA_URL) {
return Response.json({
authorization_endpoint: 'https://auth.example.test/authorize',
token_endpoint: TOKEN_URL,
});
}
if (url === TOKEN_URL) {
refreshClientId = new URLSearchParams(String(init?.body)).get('client_id') ?? undefined;
return Response.json({
access_token: 'aa_at_new',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'aa_rt_new',
scope: 'run balance:read',
});
}
throw new Error(`Unexpected URL: ${url}`);
};

await resolveApiKey({
env: {} as NodeJS.ProcessEnv,
configPath,
fetchImpl,
now: () => Date.parse('2026-08-02T12:00:00.000Z'),
});

expect(refreshClientId).toBe(expectedClientId);
expect(await readConfig(configPath)).toMatchObject({ oauthClientId: expectedClientId });
});
});

async function configWithKey(apiKey: string): Promise<string> {
Expand Down
4 changes: 4 additions & 0 deletions __tests__/bundled-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ describe('bundled agent skills', () => {
expect(onboarding).toContain('npx -y anyapi-cli@latest init');
expect(onboarding).toContain('anyapi signup --label agent');
expect(onboarding).toContain('anyapi connect');
expect(onboarding).toContain('anyapi login');
expect(onboarding).toContain('OAuth device flow');
expect(onboarding).toContain('does not bind a localhost callback');
expect(onboarding).toContain('anyapi login --api-key aa_live_...');

const run = readSkill('anyapi-run');
expect(run).toContain('anyapi run reddit.search --input');
Expand Down
68 changes: 64 additions & 4 deletions __tests__/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { AnyApiClient } from '../src/api.js';
import { connectionConfigFromToken, resolveClientId } from '../src/connect.js';
import {
OAUTH_AUTHORIZE_URL,
OAUTH_DEVICE_AUTHORIZATION_URL,
OAUTH_REGISTER_URL,
OAUTH_TOKEN_URL,
} from '../src/constants.js';
import { resolveOAuthEndpoints } from '../src/oauth.js';
import { buildAuthorizeUrl, createPkce, randomState } from '../src/pkce.js';
import type { AnyApiConfig, FetchLike, TokenResponse } from '../src/types.js';

Expand Down Expand Up @@ -68,18 +75,52 @@ describe('connectionConfigFromToken', () => {

it('maps the access token onto apiKey and stores the refresh fields', () => {
const now = new Date('2026-07-10T00:00:00.000Z');
expect(connectionConfigFromToken(token, now)).toEqual({
expect(connectionConfigFromToken(token, now, 'aa_client_active')).toEqual({
apiKey: 'aa_at_access',
refreshToken: 'aa_rt_refresh',
oauthClientId: 'aa_client_active',
scope: 'run balance:read',
accessTokenExpiresAt: '2026-07-10T01:00:00.000Z',
});
});

it('omits the expiry when expires_in is missing', () => {
const patch = connectionConfigFromToken({ ...token, expires_in: undefined as unknown as number });
expect(patch.accessTokenExpiresAt).toBeUndefined();
expect(patch.apiKey).toBe('aa_at_access');
expect(() => connectionConfigFromToken({ ...token, expires_in: undefined as unknown as number }))
.toThrow('invalid response');
});

it('rejects a non-Bearer or empty access token response', () => {
expect(() => connectionConfigFromToken({ ...token, token_type: 'MAC' })).toThrow('invalid response');
expect(() => connectionConfigFromToken({ ...token, access_token: '' })).toThrow('invalid response');
});
});

describe('OAuth endpoint discovery', () => {
it('uses endpoint-specific fallbacks when optional metadata fields are omitted', async () => {
const client = new AnyApiClient({
fetchImpl: async () => Response.json({
authorization_endpoint: 'https://auth.example.test/authorize',
token_endpoint: 'https://auth.example.test/token',
}),
});

await expect(resolveOAuthEndpoints(client)).resolves.toEqual({
authorizationEndpoint: 'https://auth.example.test/authorize',
deviceAuthorizationEndpoint: OAUTH_DEVICE_AUTHORIZATION_URL,
tokenEndpoint: 'https://auth.example.test/token',
registrationEndpoint: OAUTH_REGISTER_URL,
});
});

it('uses all hardcoded endpoints when metadata discovery fails', async () => {
const client = new AnyApiClient({ fetchImpl: async () => { throw new Error('offline'); } });

await expect(resolveOAuthEndpoints(client)).resolves.toEqual({
authorizationEndpoint: OAUTH_AUTHORIZE_URL,
deviceAuthorizationEndpoint: OAUTH_DEVICE_AUTHORIZATION_URL,
tokenEndpoint: OAUTH_TOKEN_URL,
registrationEndpoint: OAUTH_REGISTER_URL,
});
});
});

Expand Down Expand Up @@ -160,4 +201,23 @@ describe('resolveClientId', () => {
const persisted = JSON.parse(await readFile(configPath, 'utf8')) as AnyApiConfig;
expect(persisted.cliClientId).toBe('aa_client_new');
});

it('wraps DCR failures with the command context', async () => {
const client = new AnyApiClient({
fetchImpl: async () => new Response(JSON.stringify({ error: 'rate_limited' }), { status: 429 }),
});
const configPath = await makeConfigPath();

await expect(resolveClientId({}, client, REGISTRATION_ENDPOINT, configPath, {
commandName: 'anyapi login',
})).rejects.toThrow('Could not register an OAuth client for anyapi login: rate_limited');
});

it('rejects a successful DCR response with an empty client id', async () => {
const client = new AnyApiClient({ fetchImpl: async () => Response.json({ client_id: ' ' }) });
const configPath = await makeConfigPath();

await expect(resolveClientId({}, client, REGISTRATION_ENDPOINT, configPath))
.rejects.toThrow('did not return a client_id');
});
});
Loading
Loading