diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7eedf..572e654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] — 2026-09-10 + +### Changed + +- Browser login stores OAuth access/refresh tokens and the client ID instead + of minting a permanent API key. Refresh near expiry and retry once after 401. +- Serialize credential refresh, replacement, and logout across CLI processes. + Logout and replacement revoke the previous refresh token; preserve pending + grants for cleanup when login or persistence fails. +- Keep `AISA_API_KEY` and `login --key` static; migrate `~/.aisa/key` and retain + legacy credential mirrors. Credentials are stored atomically with mode 0600. +- Handle async credential errors consistently in logout, whoami, and MCP setup; + reject incomplete OAuth login responses before replacing existing credentials. + +### Known limitations + +- Already-issued access tokens remain valid until expiry after logout. +- Third-party client configurations receive a fixed access-token snapshot; + CLI refresh does not update those configurations. + ## [0.5.2] — 2026-09-10 Compatible patch on published `0.5.1`. Expanded `aisa login --help` for Agent @@ -424,7 +444,8 @@ supports today; nothing here depends on a backend change. - Config commands (`aisa config get|set|list|reset`) and auth (`aisa login|logout|whoami`). -[Unreleased]: https://github.com/AIsa-team/cli/compare/v0.5.2...HEAD +[Unreleased]: https://github.com/AIsa-team/cli/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/AIsa-team/cli/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/AIsa-team/cli/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/AIsa-team/cli/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/AIsa-team/cli/compare/v0.3.0...v0.5.0 diff --git a/README.md b/README.md index b22a35e..a7a98bc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npm install -g @aisa-one/cli ## Quick Start ```bash -# Sign in (browser; stores a CLI key — no key to copy) +# Sign in (browser; stores OAuth tokens — no key to copy) aisa login # Discover published tools (Router; search/schema may be anonymous) @@ -29,7 +29,7 @@ aisa api show financial aisa quote --input '{"calls":[{"call_id":"c1","tool":"get_financial_company_facts","arguments":{"ticker":"AAPL"}}]}' --json ``` -`aisa login` opens a browser, signs you in, and stores a CLI key. You do not +`aisa login` opens a browser, signs you in, and stores OAuth tokens. You do not need to create or paste a key from the console. For CI or scripts, set `AISA_API_KEY` or run `aisa login --key `. New accounts receive $5 in free credits. @@ -64,7 +64,7 @@ and `aisa manifest search` / `schema` / `quote` / `call` expose `mcp`, `auth`, Recommended sequence: discover a tool → `aisa schema` when `has_full_schema=false` → `aisa quote` → `aisa call`. Quote and call share one request shape. **Enforced:** invalid local input exits 2 and is not sent; -quote and call refuse to run without a configured AIsa API key. **Not +quote and call refuse to run without an OAuth session or static AIsa API key. **Not enforced:** the CLI does not record quotes, approvals, or budget caps and does not reject an unquoted call. **Instruction:** do not execute unquoted calls; the caller must ensure a matching quote and approval. Quote is a @@ -74,7 +74,7 @@ not spending approval. Do not invent tool names or guess required values. cost is not a limit. If a hard monetary cap is required, do not execute calls with no guaranteed maximum. A partial quote is not a full-batch total; call only an independently approved successful subset, and do not silently -retry. Without a configured AIsa API key, do not invent a business result. +retry. Without an OAuth session or static AIsa API key, do not invent a business result. `--input` is inline JSON (no file required). Documented shell examples use POSIX single quotes so apostrophes, Unicode, `$()`, and backticks stay @@ -104,9 +104,12 @@ HTTP error; `3` means the Router returned a batch with at least one failed item. `search` and `schema` may be anonymous. `quote` and `call` require a -configured AIsa API key. Sign in with `aisa login` first; it mints and stores -a CLI key. Resolution order is unchanged: `AISA_API_KEY`, then `~/.aisa/key`, -then legacy login. `AISA_API_KEY` still takes precedence over the stored key. +stored OAuth session or static API key. `aisa login` stores access and refresh +tokens in `~/.aisa/tokens.json` (0600), along with the OAuth client ID and +`expiresAt` (Unix milliseconds). Access tokens refresh within 60 seconds of +expiry; a 401 triggers one refresh and retry. `AISA_API_KEY` takes precedence +and never refreshes. Legacy `~/.aisa/key` files migrate on first read; conf +`apiKey` is a write-only compatibility mirror. For CI, set `AISA_API_KEY` or use `aisa login --key `. The default Router origin is `https://tools.aisa.one` (independent of `baseUrl` / `https://api.aisa.one`). Point a test Router at `AISA_ROUTER_BASE_URL` (origin @@ -323,7 +326,12 @@ Settings: independent of `baseUrl`); overridden by `AISA_ROUTER_BASE_URL` - `outputFormat` — `text` or `json` -`aisa login` stores a CLI key in `~/.aisa/key`. Environment variables: +`aisa login` stores OAuth credentials in `~/.aisa/tokens.json`. +`aisa login --key ` stores a static credential without refresh metadata. +Legacy mirrors contain the current access token; older CLIs cannot refresh it. +Third-party client configurations written by `aisa connect` also contain a +snapshot of the credential, not a refresh-capable OAuth session. +Environment variables: `AISA_API_KEY` takes precedence over the stored key. `AISA_ROUTER_BASE_URL` is the Router origin/prefix before `/v1/tool-router/...` and overrides the default `https://tools.aisa.one`. @@ -367,3 +375,20 @@ catalog metadata, not an execution recipe. ## License MIT. See [LICENSE](LICENSE). Copyright (c) 2026 AIsa Team. + +`aisa logout` revokes the stored OAuth refresh token at Clerk before deleting +local credentials and compatibility mirrors. If revocation fails, it exits +with an error and retains the credentials so you can retry. Already-issued +JWT access tokens remain valid until expiry. Static API keys are only removed +locally; an `AISA_API_KEY` environment variable must be unset separately. + +Credential reads and changes use a cross-process file lock with heartbeat and +stale-lock recovery. Re-running `aisa login` or switching to `--key` revokes +the previous stored OAuth grant before replacing it. Failed replacements +retain pending credentials in a private `.pending-tokens.json` recovery file; +the next login or logout cleans up those grants before completing. + +If a login cannot acquire the credential lock, the newly issued grant is kept +in a private `.pending-login-*.json` file for the next login/logout to clean up. +Refresh persists the new credentials before optional rotation hints and legacy +mirrors; failure to save the primary token file is reported explicitly. diff --git a/docs/release.md b/docs/release.md index e1d3d36..eb5269d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -8,16 +8,16 @@ that commit is merged and reviewed. A push to `main` runs CI only; | Item | Value | | --- | --- | -| Version | `0.5.2` (release target) | +| Version | `0.6.0` (release target) | | Command surface | 22 root help entries including implicit `help`; `api` is `list`/`show` only | -| Registry latest | `0.5.1` on `https://registry.npmjs.org` (baseline at this preparation; recheck before tagging) | +| Registry latest | `0.5.2` on `https://registry.npmjs.org` (baseline at this preparation; recheck before tagging) | | Default Router origin | `https://tools.aisa.one` | | LLM / catalog host | `https://api.aisa.one` | | Node | `engines` `>=18`. CI on Ubuntu: 18/20 legacy compatibility, 22/24 maintained, 26 current. Publish job uses Node 24 and npm `11.6.0`. | `package.json`, `package-lock.json` (root / `packages[""]`), `src/constants.ts` `VERSION`, installed `aisa --version`, and -`CHANGELOG.md` `## [0.5.2]` must agree. Confirm with +`CHANGELOG.md` `## [0.6.0]` must agree. Confirm with `node scripts/package-smoke.mjs` (or `--tarball` of the candidate archive). The VS Code extension is not version-bumped with this CLI release unless its own packaging requires it. @@ -45,17 +45,17 @@ official registry agree. ```bash # Official registry only — do not use a mirror as the source of truth. npm view @aisa-one/cli version --registry https://registry.npmjs.org -# baseline at this preparation: 0.5.1 — recheck before tagging +# baseline at this preparation: 0.5.2 — recheck before tagging # 0.4.0 is the unpublished main baseline, not a registry release. git checkout main git pull origin main -# Confirm this commit is the reviewed merge of the 0.5.2 candidate. -node -p "require('./package.json').version" # 0.5.2 -grep -E '^export const VERSION' src/constants.ts # "0.5.2" +# Confirm this commit is the reviewed merge of the 0.6.0 candidate. +node -p "require('./package.json').version" # 0.6.0 +grep -E '^export const VERSION' src/constants.ts # "0.6.0" -git tag -a v0.5.2 -m "v0.5.2" -git push origin v0.5.2 +git tag -a v0.6.0 -m "v0.6.0" +git push origin v0.6.0 ``` Do not tag a worktree or unmerged branch. Do not run `npm publish` on a @@ -78,7 +78,7 @@ again via `prepack`). It: Local smoke of an existing archive: ```bash -node scripts/package-smoke.mjs --tarball /path/to/aisa-one-cli-0.5.2.tgz +node scripts/package-smoke.mjs --tarball /path/to/aisa-one-cli-0.6.0.tgz ``` `prepack` (`npm run build`) is what puts `dist/` into a clean `npm pack`. @@ -86,6 +86,6 @@ CI still runs an explicit `npm run build` before `npm test`. ## After the tag -Watch the Release workflow. Success is `0.5.2` on +Watch the Release workflow. Success is `0.6.0` on `https://registry.npmjs.org/@aisa-one/cli`. Recheck the official registry before assuming the tag published. Never move an existing release tag. diff --git a/package-lock.json b/package-lock.json index 1d47d06..f007483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@aisa-one/cli", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aisa-one/cli", - "version": "0.5.2", + "version": "0.6.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", "commander": "^12.0.0", "conf": "^12.0.0", "ora": "^8.0.1", + "proper-lockfile": "^4.1.2", "smol-toml": "^1.8.0" }, "bin": { @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", + "@types/proper-lockfile": "^4.1.4", "typescript": "^5.0.0", "vitest": "^3.2.4" }, @@ -834,6 +836,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1313,6 +1332,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -1542,6 +1567,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1565,6 +1607,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2422,6 +2473,21 @@ "undici-types": "~6.21.0" } }, + "@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, + "@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true + }, "@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -2725,6 +2791,11 @@ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==" }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -2863,6 +2934,23 @@ "source-map-js": "^1.2.1" } }, + "proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "requires": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + } + } + }, "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2877,6 +2965,11 @@ "signal-exit": "^4.1.0" } }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + }, "rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", diff --git a/package.json b/package.json index 8baab7f..77f0836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aisa-one/cli", - "version": "0.5.2", + "version": "0.6.0", "description": "CLI for the AIsa unified AI infrastructure platform - one API key for 80+ LLMs and 900+ endpoints across finance, search, social, and video APIs", "type": "module", "main": "dist/index.js", @@ -36,10 +36,12 @@ "commander": "^12.0.0", "conf": "^12.0.0", "ora": "^8.0.1", + "proper-lockfile": "^4.1.2", "smol-toml": "^1.8.0" }, "devDependencies": { "@types/node": "^20.0.0", + "@types/proper-lockfile": "^4.1.4", "typescript": "^5.0.0", "vitest": "^3.2.4" }, diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 1e54743..4a1ecec 100755 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -23,7 +23,7 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, ".."); const FAKE_KEY = "local-smoke-key"; -const MISSING_KEY = /No API key found[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY/; +const MISSING_KEY = /Not authenticated\.[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY/; const BIG = "9007199254740993"; const SEARCH_REQ = '{"query":"company facts","limit":3}'; diff --git a/src/api.ts b/src/api.ts index 1695e8e..3ca9c31 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,3 +1,4 @@ +import { authenticatedFetch } from "./utils/auth-http.js"; import { BASE_URL } from "./constants.js"; import { httpFetch, INFO_TIMEOUT_MS } from "./utils/http.js"; import { getConfig } from "./config.js"; @@ -77,7 +78,7 @@ export interface RequestOptions { } export async function apiRequest( - apiKey: string, + accessToken: string, endpoint: string, options: RequestOptions = {} ): Promise> { @@ -92,13 +93,13 @@ export async function apiRequest( } const headers: Record = { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "x-aisa-source": "cli", ...extraHeaders, }; - const res = await httpFetch(url, { + const res = await authenticatedFetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, @@ -123,7 +124,7 @@ export async function apiRequest( } export async function apiRequestRaw( - apiKey: string, + accessToken: string, endpoint: string, options: RequestOptions = {} ): Promise { @@ -138,13 +139,13 @@ export async function apiRequestRaw( } const headers: Record = { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "x-aisa-source": "cli", ...extraHeaders, }; - return httpFetch(url, { + return authenticatedFetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, diff --git a/src/commands/account.ts b/src/commands/account.ts index 8089a41..5c6e9ee 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -1,6 +1,6 @@ import { run } from "../utils/exec.js"; import chalk from "chalk"; -import { requireApiKey } from "../config.js"; +import { requireAccessToken } from "../config.js"; import { apiRequest } from "../api.js"; import { formatJson, hint, info, error } from "../utils/display.js"; import { CONSOLE_BILLING_URL } from "../constants.js"; @@ -20,7 +20,7 @@ export function formatMicrosUSD(micros: number | string | bigint): string { } export async function balanceAction(options: { json?: boolean } = {}): Promise { - const key = requireApiKey(); + const key = await requireAccessToken(); const res = await apiRequest(key, "credits/balance"); if (!res.success || !res.data) { @@ -86,7 +86,7 @@ export function topupAction(amount: string | undefined, options: { open?: boolea } export async function usageAction(_options: { limit?: string; days?: string }): Promise { - requireApiKey(); + await requireAccessToken(); // The gateway does not serve /v1/credits/usage yet — it 404s in production // even though /v1/credits/balance on the same route group works. console.log(chalk.yellow(" Usage API is not yet available on the gateway.")); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 67d399a..ce9bd62 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,12 +1,12 @@ import chalk from "chalk"; -import { setApiKey, clearApiKey, getApiKey, getKeySource, maskKey, AUTH_SETUP_GUIDANCE } from "../config.js"; +import { replaceTokens, revokeAndClearTokens, getAccessToken, getKeySource, maskKey, AUTH_SETUP_GUIDANCE } from "../config.js"; import { success, error, info } from "../utils/display.js"; import { CONSOLE_URL, ENV_VAR_NAME } from "../constants.js"; export async function loginAction(options: { key?: string; browser?: boolean }): Promise { const key = options.key || process.env[ENV_VAR_NAME]; if (key) { - setApiKey(key); + await replaceTokens(key); success(`Authenticated: ${maskKey(key)}`); await proveItWorks(); return; @@ -22,18 +22,7 @@ export async function loginAction(options: { key?: string; browser?: boolean }): await proveItWorks(); } -/** - * Use the key we just stored, and show what came back. - * - * "Signed in — key stored" says a file was written. It does not say the key - * works, and those are different claims: a pasted key can be the wrong one, a - * minted one can belong to an account with no credit. Ending on a balance - * turns the question "did that work?" into something already answered on - * screen, which is what someone finishing a sign-in actually wants to know. - * - * Failure here is not a failed sign-in — the key is stored either way — so it - * says what it could not do and points at the command to retry with. - */ +/** Show the balance after storing credentials; a balance failure does not undo login. */ async function proveItWorks(): Promise { try { const { balanceAction } = await import("./account.js"); @@ -48,13 +37,15 @@ async function proveItWorks(): Promise { console.log(chalk.gray(` Account, usage and top-ups — ${chalk.cyan(CONSOLE_URL)}`)); } -export function logoutAction(): void { - clearApiKey(); - success("Logged out. API key removed."); +export async function logoutAction(): Promise { + const revoked = await revokeAndClearTokens(); + success(revoked ? "Logged out. OAuth refresh token revoked and stored tokens removed." : "Logged out. Stored tokens removed."); + if (revoked) info("Already-issued access tokens remain valid until they expire."); + if (process.env[ENV_VAR_NAME]) info(`${ENV_VAR_NAME} is still set. Unset it to stop using that API key.`); } -export function whoamiAction(): void { - const key = getApiKey(); +export async function whoamiAction(): Promise { + const key = await getAccessToken(); const source = getKeySource(); if (!key) { @@ -63,6 +54,6 @@ export function whoamiAction(): void { return; } - console.log(` Key: ${maskKey(key)}`); - console.log(` Source: ${source === "env" ? `${ENV_VAR_NAME} env var` : "stored config"}`); + console.log(` Token: ${maskKey(key)}`); + console.log(` Source: ${source === "env" ? `${ENV_VAR_NAME} env var` : "~/.aisa/tokens.json"}`); } diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 275383f..eb423de 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -1,6 +1,6 @@ import ora from "ora"; import chalk from "chalk"; -import { requireApiKey, getConfig } from "../config.js"; +import { requireAccessToken, getConfig } from "../config.js"; import { apiRequest, apiRequestRaw } from "../api.js"; import { error } from "../utils/display.js"; import { handleSSEStream } from "../utils/streaming.js"; @@ -17,7 +17,7 @@ export async function chatAction( temperature?: string; } ): Promise { - const key = requireApiKey(); + const key = await requireAccessToken(); // Read from stdin if no message provided let text = message; diff --git a/src/commands/configCmd.ts b/src/commands/configCmd.ts index 99e3f01..fdd86a5 100644 --- a/src/commands/configCmd.ts +++ b/src/commands/configCmd.ts @@ -44,6 +44,7 @@ export function configGetAction(key: string): void { export function configListAction(): void { const all = listConfig(); const display = { ...all }; + if (display.tokens) display.tokens = "****"; if (display.apiKey) { display.apiKey = "****"; } diff --git a/src/commands/connect.ts b/src/commands/connect.ts index d20ebd9..423da95 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -9,7 +9,7 @@ import chalk from "chalk"; import { success, error, info, hint } from "../utils/display.js"; import { expandHome } from "../utils/file.js"; import { MCP_CONFIGS, MCP_DEFAULT_SLUGS, AISA_PROVIDER_ID } from "../constants.js"; -import { getApiKey, getConfig, setConfig } from "../config.js"; +import { getAccessToken, getConfig, setConfig } from "../config.js"; import { fetchLiveServers, writeClientConfig, buildEntry, stripped, type LiveServer } from "./mcp.js"; import { INSTALLERS, installAgent, isInstalled, supported } from "./install.js"; import { @@ -23,7 +23,7 @@ import { DEFAULT_MODELS, } from "./llm-config.js"; import { writeClaudeAisaSettings, installWrappers } from "./wrappers.js"; -import { mintCliKey, type OAuthCatcher } from "./oauth-login.js"; +import { signInAndStoreTokens, type OAuthCatcher } from "./oauth-login.js"; import { canOpenBrowser } from "../utils/browser.js"; import { vscodeDetected, vscodeUserDir, writeVSCodeLLM, writeVSCodeMCP, installVSCodeExtension, launchVSCode, VSCODE_MODELS } from "./vscode.js"; import { formatMicrosUSD } from "./account.js"; @@ -84,9 +84,9 @@ export function resolveTemplate(flag: string | undefined): ConnectTemplate { * sign in, exit. No daemon, no terminal takeover, no prompt or skill * injection into the user's agent. The user stays in their own Claude Code. * - Sign-in is one OAuth round for everything: with no key stored, the run - * starts with the same browser approval `aisa login` uses, which mints the - * durable "aisa cli" key (POST /v1/keys/mint). Every MCP entry is then - * written as a bearer and the model provider gets the same key — zero + * starts with the same browser approval `aisa login` uses. Every MCP entry + * is written with the current access token and the model provider gets + * the same token — zero * per-server authorization popups. Only if that sign-in fails do we fall * back to each client's own OAuth machinery (`claude mcp login ` per * server; `codex mcp add` runs its own flow), which still works but costs @@ -456,7 +456,7 @@ const CODEX_KEY_ENV_VAR = "AISA_API_KEY"; /** Below this the balance step lingers and nudges towards a top-up. */ const LOW_BALANCE_MICROS = 5_000_000; -/** The manual fallback when the inline sign-in cannot mint a key. */ +/** The manual fallback when the inline sign-in cannot obtain an access token. */ const CONSOLE_KEYS_URL = "https://console.aisa.one/api-keys"; const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -513,7 +513,7 @@ function buildPlan(input: PlanInput): Step[] { : "npm install -g @aisa-one/cli — the aisa command for balance, top-up and key rotation", }); // One sign-in, before anything that wants a credential: the browser - // approval mints the durable CLI key, and with it every MCP entry is a + // approval obtains the access token, and with it every MCP entry is a // bearer and the model provider can be written — no per-server popups. // If it fails at run time the per-server OAuth rounds come back as a // fallback (added to the plan then, not promised now). @@ -529,7 +529,7 @@ function buildPlan(input: PlanInput): Step[] { // they had signed in before. detail: input.keyRejected ? "your stored key is no longer accepted — one browser approval replaces it" - : "one browser approval — it mints your CLI key", + : "one browser approval — it stores your OAuth session", }); } const web = input.clients[0] === "claude-ai"; @@ -752,7 +752,7 @@ async function runPlan(state: RunState, input: RunInput, log: Journal): Promise< if (input.dryRun) { ok("signin", "dry run — the browser approval would open here"); } else { - key = await mintCliKey({ lang: input.lang, catcher: input.catcher }); + key = await signInAndStoreTokens({ lang: input.lang, catcher: input.catcher }); // Two facts, in the order they matter to the person who just left // the page to go and do this: it worked, and here is what it got // them. The old line led with the artifact — "your CLI key is @@ -879,7 +879,7 @@ async function runPlan(state: RunState, input: RunInput, log: Journal): Promise< ok("llm", "dry run — nothing written"); } else if (!key) { // Only reachable when the sign-in above failed (or was declined): the - // normal path mints a key before this step runs. A provider entry has + // normal path obtains an access token before this step runs. A provider entry has // nowhere to put an OAuth token, so without a key the fallback is the // console page that hands them out, with exact instructions. setStep(state, "llm", { @@ -1233,10 +1233,10 @@ function renderPage( clients.map((c) => [c.id, defaultModelsFor(c.id).model]) ); const authCopy = keyed - ? `Your configured AIsa API key is written into each entry — no sign-in needed.` + ? `Your current AIsa credential is written into each entry — no sign-in needed.` : `One sign-in, nothing to paste. Your browser opens the AIsa approval - once; it issues a long-lived key for this machine, and every server - and model below is configured with it — no further popups.`; + once; every server and model below is configured with the current + access token. Reconnect when that token expires.`; const body = `
Connect
@@ -2469,7 +2469,7 @@ export async function connectAction(options: { process.exitCode = 1; return; } - const key = getApiKey(); + const key = await getAccessToken(); // A key that exists is not a key that works. One revoked from the console, // or belonging to a deleted account, sits in ~/.aisa/key looking exactly // like a good one — and the run then skipped the sign-in, wrote the dead diff --git a/src/commands/flow.ts b/src/commands/flow.ts index 1ce974a..abc66cb 100644 --- a/src/commands/flow.ts +++ b/src/commands/flow.ts @@ -465,12 +465,12 @@ export const STEP_INSTALL = { zh: "下面是将要发生的全部事情,按顺序列出。它会自动开始;每一步完成后都会汇报,结果会在最后一步打开。", }, authKeyed: { - en: "Your configured AIsa API key is written into each entry — no sign-in needed.", - zh: "你已配置的 AIsa API key 会写进每一条配置 —— 无需登录。", + en: "Your current AIsa credential is written into each entry — no sign-in needed.", + zh: "你当前的 AIsa 凭证会写进每一条配置 —— 无需登录。", }, authFresh: { - en: "One sign-in, nothing to paste. Your browser opens the AIsa approval once; it issues a long-lived key for this machine, and every server and model is configured with it.", - zh: "登录一次,不用粘贴任何东西。浏览器会打开一次 AIsa 授权页;它会为这台机器签发一把长期有效的 key,所有 server 和模型都用它来配置。", + en: "One sign-in, nothing to paste. Your browser opens the AIsa approval once; every server and model is configured with the current access token. Reconnect when that token expires.", + zh: "登录一次,不用粘贴任何东西。浏览器会打开一次 AIsa 授权页;所有 server 和模型都使用当前 access token 配置。token 过期后请重新连接。", }, }; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 5457b9e..1df9278 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,7 +4,7 @@ import { success, error, info, hint } from "../utils/display.js"; import { expandHome, ensureDir } from "../utils/file.js"; import { MCP_CONFIGS, MCP_MANIFEST_URL, MCP_CATALOG_URL, DOCS_MCP_URL, MCP_DEFAULT_SLUGS } from "../constants.js"; import { httpFetch, MAX_ATTEMPTS } from "../utils/http.js"; -import { getApiKey } from "../config.js"; +import { getAccessToken } from "../config.js"; import { detectClients } from "./connect.js"; import { join } from "node:path"; @@ -219,7 +219,7 @@ export async function mcpSetupAction( : servers.filter((s) => MCP_DEFAULT_SLUGS.includes(s.slug)); const skipped = servers.length - chosen.length; - const key = getApiKey(); + const key = await getAccessToken(); if (key) { info("Using your configured API key (Bearer). It will be written into each client's config file."); } else { diff --git a/src/commands/models.ts b/src/commands/models.ts index 188ee2d..3b58acb 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -1,6 +1,6 @@ import ora from "ora"; import chalk from "chalk"; -import { requireApiKey } from "../config.js"; +import { requireAccessToken } from "../config.js"; import { apiRequest } from "../api.js"; import { error, badge } from "../utils/display.js"; import type { Model } from "../types.js"; @@ -11,7 +11,7 @@ const modelsCacheKey = () => `models/${cacheScope()}.json`; const MODELS_TTL_MS = 24 * 60 * 60 * 1000; export async function modelsListAction(options: { provider?: string }): Promise { - const key = requireApiKey(); + const key = await requireAccessToken(); const spinner = ora("Fetching models...").start(); const res = await apiRequest<{ data: Model[] }>(key, "models"); @@ -59,7 +59,7 @@ export async function modelsListAction(options: { provider?: string }): Promise< } export async function modelsShowAction(modelId: string): Promise { - const key = requireApiKey(); + const key = await requireAccessToken(); const spinner = ora(`Loading ${modelId}...`).start(); const res = await apiRequest(key, `models/${modelId}`); diff --git a/src/commands/oauth-login.ts b/src/commands/oauth-login.ts index d6e0293..8b12eeb 100644 --- a/src/commands/oauth-login.ts +++ b/src/commands/oauth-login.ts @@ -4,7 +4,7 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { createInterface } from "node:readline/promises"; import { error, hint, info, success } from "../utils/display.js"; -import { setApiKey } from "../config.js"; +import { replaceTokens, tokenExpiresAt } from "../config.js"; import { maskKey } from "../config.js"; import { httpFetch } from "../utils/http.js"; import { canOpenBrowser } from "../utils/browser.js"; @@ -14,16 +14,14 @@ import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js"; /** * `aisa login` without a key: sign in once in a browser, come back with the - * CLI's long-lived key. + * CLI's access and refresh tokens. * * The flow is the standard one every CLI converges on (gh, flyctl, claude): * * 1. register a public OAuth client (Clerk supports dynamic registration) * 2. authorization-code + PKCE, redirecting to a loopback port * 3. exchange the code for an access token - * 4. trade that token for the durable "aisa cli" key at /v1/keys/mint, - * and store the key — the token itself is then dropped. One secret on - * disk, and it is the one that does not expire in a day. + * 4. store access/refresh tokens and the registered client ID for silent refresh. * * A machine with no browser of its own takes the paste-back variant, and it * is chosen for the user rather than asked for: the URL is printed, the user @@ -34,7 +32,7 @@ import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js"; * is a one-time code, not a key. */ -const AUTH_SERVER = "https://clerk.aisa.one"; +import { AUTH_SERVER } from "../constants.js"; /** * Where the sign-in lands when the browser is on a different machine. * @@ -48,13 +46,15 @@ const AUTH_SERVER = "https://clerk.aisa.one"; * have to be added alongside. */ const HOSTED_REDIRECT = "https://aisa.one/cli/auth"; -const MINT_URL = "https://api.aisa.one/v1/keys/mint"; + const b64url = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); interface TokenResponse { access_token?: string; + refresh_token?: string; + expires_in?: number; error?: string; error_description?: string; } @@ -66,10 +66,10 @@ async function registerClient(redirectUri: string): Promise { body: JSON.stringify({ client_name: "AIsa CLI", redirect_uris: [redirectUri], - grant_types: ["authorization_code"], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", - scope: "openid profile email", + scope: "openid profile email offline_access", }), signal: AbortSignal.timeout(15_000), }); @@ -248,7 +248,7 @@ function openBrowser(url: string): void { /** * The whole sign-in, as a function other commands can embed: browser (or - * paste-back) OAuth, then the mint. Stores the key and returns it; throws on + * paste-back) OAuth. Stores tokens and returns the access token; throws on * any failure. `aisa login` wraps this with CLI messaging; `aisa connect` * runs it as its "Sign in to AIsa" step. */ @@ -270,7 +270,7 @@ export interface OAuthCatcher { wait(expectedState: string): Promise; } -export async function mintCliKey( +export async function signInAndStoreTokens( options: { open?: boolean; lang?: Lang; catcher?: OAuthCatcher } = {} ): Promise { const lang: Lang = options.lang ?? "en"; @@ -301,7 +301,7 @@ export async function mintCliKey( response_type: "code", client_id: clientId, redirect_uri: redirectUri, - scope: "openid profile email", + scope: "openid profile email offline_access", state, code_challenge: challenge, code_challenge_method: "S256", @@ -347,31 +347,15 @@ export async function mintCliKey( signal: AbortSignal.timeout(20_000), }); const tokens = (await tokenRes.json()) as TokenResponse; - if (!tokens.access_token) { + if (!tokenRes.ok || !tokens.access_token) { throw new Error(`token exchange failed: ${tokens.error_description ?? tokens.error ?? tokenRes.status}`); } - - // The token is a day-long credential; the key is the durable one. Trade up - // and keep only the key. - const mintRes = await httpFetch(MINT_URL, { - method: "POST", - headers: { Authorization: `Bearer ${tokens.access_token}` }, - signal: AbortSignal.timeout(20_000), - }); - if (mintRes.status === 404) { - // A deployment without the mint endpoint. The sign-in still proved the - // account works; the key just has to travel by hand once. - throw new Error( - "this deployment cannot issue CLI keys — copy one from https://console.aisa.one/api-keys and run: aisa login --key " - ); - } - const minted = (await mintRes.json()) as { key?: string; error?: string }; - if (!mintRes.ok || !minted.key) { - throw new Error(`could not issue a key: ${minted.error ?? `HTTP ${mintRes.status}`}`); + if (typeof tokens.refresh_token !== "string" || !tokens.refresh_token.trim()) { + throw new Error("token exchange did not return a refresh token. Existing credentials were kept; retry sign-in with offline access enabled."); } - setApiKey(minted.key); - return minted.key; + await replaceTokens(tokens.access_token, tokens.refresh_token, tokenExpiresAt(tokens.expires_in), clientId); + return tokens.access_token; } /** Commander after-help for `aisa login`. Mechanics only; runtime is unchanged. */ @@ -385,7 +369,7 @@ If this process already timed out, or the URL/code is from an older run, start a No browser and no TTY: this CLI cannot complete OAuth here. Normal setup is native MCP OAuth at https://tools.aisa.one/mcp. -Do not report connected unless login stored a key and the following balance check succeeded. aisa whoami and a stored local key are not proof. Scripts/CI: AISA_API_KEY or --key. +Do not report connected unless login stored credentials and the following balance check succeeded. aisa whoami and stored local credentials are not proof. Scripts/CI: AISA_API_KEY or --key. `; } @@ -396,8 +380,8 @@ export async function oauthLogin(options: { open?: boolean; lang?: Lang } = {}): process.exitCode = 1; return; } - const key = await mintCliKey(options); + const key = await signInAndStoreTokens(options); // The balance follows from the caller, so no "try aisa balance" here: being // told to go and check is worse than being shown. - success(`Signed in — CLI key ${maskKey(key)} stored`); + success(`Signed in — access token ${maskKey(key)} stored`); } diff --git a/src/commands/signin-page.ts b/src/commands/signin-page.ts index db96cd1..28de233 100644 --- a/src/commands/signin-page.ts +++ b/src/commands/signin-page.ts @@ -55,14 +55,14 @@ const ENDING_MARKUP = [ " function tick() {", " var left = Math.ceil((until - Date.now()) / 1000);", " if (left <= 0) {", - ' show("This page has closed", "Nothing here is needed any more \u2014 your key was saved on the machine you signed in from.");', + ' show("This page has closed", "Nothing here is needed any more \u2014 return to your terminal to check the sign-in result.");', " return;", " }", // Silent until the last minute, as before: a countdown running for five // minutes is furniture. What changed is that it ends in a dialog rather // than in a line nobody was looking at. " if (left > 60) { setTimeout(tick, 1000); return; }", - ' show("This page closes in " + left + " seconds", "You can close it now \u2014 your key is already saved.");', + ' show("This page closes in " + left + " seconds", "You can close it now \u2014 check your terminal for the sign-in result.");', " setTimeout(tick, 1000);", " }", " tick();", @@ -75,15 +75,14 @@ export type SignInOutcome = "ok" | "failed" | "expired"; interface Outcome { kicker: string; title: string; - body: string; + body?: string; cta?: { href: string; domain: string; before: string; after: string }; } const COPY: Record = { ok: { - kicker: "SIGNED IN", - title: "You're all set", - body: "Your key was created on the machine you started from — it never travelled through this browser. You can close this tab.", + kicker: "AUTHORIZED", + title: "Return to your terminal", /** * The one moment a mention of the console is welcome rather than in the * way — and a sentence, not a button. @@ -184,7 +183,7 @@ export function renderSignInPage(outcome: SignInOutcome, closesAt?: number): str
${good ? "✓" : "!"}${c.kicker}

${c.title}

-

${c.body}

+ ${c.body ? `

${c.body}

` : ""} ${c.cta ? `

${c.cta.before}${c.cta.domain}${c.cta.after}

` : ""} diff --git a/src/commands/tools.ts b/src/commands/tools.ts index 8014046..580e55b 100644 --- a/src/commands/tools.ts +++ b/src/commands/tools.ts @@ -1,6 +1,6 @@ import ora from "ora"; import chalk from "chalk"; -import { getApiKey, MISSING_API_KEY_GUIDANCE } from "../config.js"; +import { getAccessToken, MISSING_API_KEY_GUIDANCE } from "../config.js"; import { CliError, EXIT_PARTIAL, EXIT_TRANSPORT, transportError } from "../cli-error.js"; import { routerPost, type RouterOperation } from "../router.js"; import { error as printError } from "../utils/display.js"; @@ -36,7 +36,7 @@ async function runToolCommand( auth: { auth: "optional" | "required" } ): Promise { const prepared = await prepareRouterRequest(kind, operands, options); - const apiKey = auth.auth === "required" ? requireRouterKey(kind) : getApiKey(); + const accessToken = auth.auth === "required" ? await requireRouterKey(kind) : await getAccessToken(); const spinner = options.json ? undefined : ora(spinnerText(kind)).start(); @@ -45,7 +45,7 @@ async function runToolCommand( result = await routerPost({ operation: kind as RouterOperation, body: prepared.body, - apiKey, + accessToken, }); } catch (err) { spinner?.fail("Request failed"); @@ -75,12 +75,12 @@ async function runToolCommand( } } -function requireRouterKey(kind: RouterKind): string { - const key = getApiKey(); +async function requireRouterKey(kind: RouterKind): Promise { + const key = await getAccessToken(); if (!key) { throw new CliError( `${MISSING_API_KEY_GUIDANCE} ` + - `search and schema may be anonymous; ${kind} will not run without a key. ` + + `search and schema may be anonymous; ${kind} will not run without authentication. ` + `Do not invent a business result.`, EXIT_TRANSPORT ); diff --git a/src/config.ts b/src/config.ts index fee1e16..fe0e95d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,29 +1,102 @@ import Conf from "conf"; -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { ENV_VAR_NAME } from "./constants.js"; +import lockfile from "proper-lockfile"; +import { createHash, randomUUID } from "node:crypto"; +import { httpFetch } from "./utils/http.js"; +import { AUTH_SERVER, MISSING_API_KEY_GUIDANCE, ENV_VAR_NAME } from "./constants.js"; -// ~/.aisa is the credential home: one visible, portable, platform-stable -// place for the key, next door to ~/.claude and ~/.codex. The conf store -// (platform-specific path, invisible to other tools) remains a legacy read -// source and a sync target so older CLI versions keep working. +// tokens.json is authoritative; conf is a write-only compatibility mirror. const aisaDir = () => join(homedir(), ".aisa"); const keyFile = () => join(aisaDir(), "key"); +const tokenFile = () => join(aisaDir(), "tokens.json"); -function readKeyFile(): string | undefined { +export interface StoredTokens { + accessToken: string; + refreshToken?: string; + /** Unix timestamp in milliseconds. */ + expiresAt?: number; + clientId?: string; +} + +// Only call under withTokenLock, except the read-only source indicator. +function readTokenFile(): StoredTokens | undefined { try { - const k = readFileSync(keyFile(), "utf-8").trim(); - return k || undefined; - } catch { - return undefined; + const tokens = JSON.parse(readFileSync(tokenFile(), "utf-8")); + if (typeof tokens?.accessToken !== "string" || !tokens.accessToken) throw new Error("Invalid token file. Restore it before changing credentials."); + return tokens; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function readLegacyKey(): string | undefined { + try { return readFileSync(keyFile(), "utf-8").trim() || undefined; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; } } -function writeKeyFile(key: string): void { +function readTokens(): StoredTokens | undefined { + const tokens = readTokenFile(); + if (tokens) return tokens; + const accessToken = readLegacyKey(); + if (!accessToken) return undefined; + const migrated = { accessToken }; + writeTokens(migrated); + return migrated; +} + +async function withTokenLock(work: () => Promise | T): Promise { mkdirSync(aisaDir(), { recursive: true }); - writeFileSync(keyFile(), key + "\n", { mode: 0o600 }); - chmodSync(keyFile(), 0o600); + let compromised: Error | undefined; + const release = await lockfile.lock(tokenFile(), { + realpath: false, + stale: 30_000, + update: 1_000, + retries: { retries: 400, factor: 1, minTimeout: 100, maxTimeout: 100 }, + onCompromised: (error) => { compromised = error; }, + }); + // Fail closed before any writes if a suspended process loses its lease. + const previousGuard = assertTokenLock; + assertTokenLock = () => { if (compromised) throw compromised; }; + try { return await work(); } + finally { + assertTokenLock = previousGuard; + await release(); + } +} + +let assertTokenLock = () => {}; +const rotationFile = () => join(aisaDir(), ".token-rotation.json"); +const pendingFile = () => join(aisaDir(), ".pending-tokens.json"); +const tokenHash = (token: string) => createHash("sha256").update(token).digest("hex"); + +function writeJson(path: string, value: unknown): void { + assertTokenLock(); + writeIndependentJson(path, value); +} + +// Unique recovery files do not share a name and can be created without the main lock. +function writeIndependentJson(path: string, value: unknown): void { + mkdirSync(aisaDir(), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporary, JSON.stringify(value) + "\n", { mode: 0o600 }); + renameSync(temporary, path); + } finally { + if (existsSync(temporary)) unlinkSync(temporary); + } +} + +function removeFile(path: string): void { + assertTokenLock(); + try { unlinkSync(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } } /** @@ -45,6 +118,7 @@ const config = new Conf({ projectName: "aisa-cli", configFileMode: CONFIG_FILE_MODE, schema: { + tokens: { type: "object", default: {} }, apiKey: { type: "string", default: "" }, defaultModel: { type: "string", default: "gpt-4.1-mini" }, baseUrl: { type: "string", default: "https://api.aisa.one/v1" }, @@ -68,58 +142,225 @@ try { /* not ours to chmod, or gone — neither is worth failing a command over */ } -export function getApiKey(): string | undefined { - const envKey = process.env[ENV_VAR_NAME]; - if (envKey) return envKey; - const fileKey = readKeyFile(); - if (fileKey) return fileKey; - const stored = config.get("apiKey") as string; - if (stored) { - // Legacy location — migrate on first read so every other tool (wrappers, - // scripts) finds the key at the one agreed place from now on. - try { - writeKeyFile(stored); - } catch { - /* migration is best-effort; the key itself is still returned */ +/** Next step when no local credential is present. */ +export { AUTH_SETUP_GUIDANCE, MISSING_API_KEY_GUIDANCE } from "./constants.js"; + +export async function getAccessToken(): Promise { + if (process.env[ENV_VAR_NAME]) return process.env[ENV_VAR_NAME]; + return withTokenLock(async () => { + const tokens = readTokens(); + if (!tokens) return undefined; + if (canRefresh(tokens) && typeof tokens.expiresAt === "number" && tokens.expiresAt <= Date.now() + 60_000) { + return (await refreshLocked(tokens)) ?? tokens.accessToken; } - return stored; + return tokens.accessToken; + }); +} + +export async function requireAccessToken(): Promise { + const token = await getAccessToken(); + if (!token) { + console.error(MISSING_API_KEY_GUIDANCE); + process.exit(1); } - return undefined; + return token; } -/** Next step when no local credential is present. Browser login first; env/--key are CI. */ -export const AUTH_SETUP_GUIDANCE = - `Run "aisa login". For CI, set ${ENV_VAR_NAME} or use "aisa login --key ".`; +function makeTokens(accessToken: string, refreshToken?: string, expiresAt?: number, clientId?: string): StoredTokens { + return accessToken.startsWith("sk-") ? { accessToken } : { accessToken, refreshToken, expiresAt, clientId }; +} -export const MISSING_API_KEY_GUIDANCE = `No API key found. ${AUTH_SETUP_GUIDANCE}`; +/** Low-level storage; interactive login must use replaceTokens to retire the previous grant. */ +export async function storeTokens(accessToken: string, refreshToken?: string, expiresAt?: number, clientId?: string): Promise { + await withTokenLock(() => writeTokens(makeTokens(accessToken, refreshToken, expiresAt, clientId))); +} -export function requireApiKey(): string { - const key = getApiKey(); - if (!key) { - console.error(MISSING_API_KEY_GUIDANCE); - process.exit(1); +function writeTokens(tokens: StoredTokens, previousAccessToken?: string): void { + assertTokenLock(); + // The rotated credential is authoritative. Auxiliary metadata must never + // prevent its persistence or turn a successful refresh into an old-token fallback. + writeJson(tokenFile(), tokens); + try { + if (previousAccessToken) { + writeJson(rotationFile(), { previous: tokenHash(previousAccessToken), current: tokenHash(tokens.accessToken) }); + } else removeFile(rotationFile()); + } catch { /* rotation hints are optional; their hashes are checked before reuse */ } + try { + config.set("tokens", tokens); + config.set("apiKey", tokens.accessToken); + writeFileSync(keyFile(), tokens.accessToken + "\n", { mode: 0o600 }); + chmodSync(keyFile(), 0o600); + } catch { + console.error("Credentials saved, but legacy credential mirrors could not be updated."); } - return key; } -export function setApiKey(key: string): void { - writeKeyFile(key); - config.set("apiKey", key); +function canRefresh(tokens: StoredTokens): boolean { + return !tokens.accessToken.startsWith("sk-") && + typeof tokens.refreshToken === "string" && !!tokens.refreshToken && + typeof tokens.clientId === "string" && !!tokens.clientId; +} + +/** All reads, rotation and persistence are serialized across CLI processes. */ +export async function refreshAccessToken(accessToken?: string): Promise { + if (process.env[ENV_VAR_NAME]) return undefined; + return withTokenLock(async () => { + const tokens = readTokens(); + if (!tokens || !canRefresh(tokens)) return undefined; + if (accessToken && accessToken !== tokens.accessToken) { + try { + const rotation = JSON.parse(readFileSync(rotationFile(), "utf-8")); + return rotation.previous === tokenHash(accessToken) && rotation.current === tokenHash(tokens.accessToken) + ? tokens.accessToken : undefined; + } catch { return undefined; } + } + return refreshLocked(tokens); + }); } -export function clearApiKey(): void { +async function refreshLocked(tokens: StoredTokens): Promise { + let next: { access_token?: string; refresh_token?: string; expires_in?: number }; try { - if (existsSync(keyFile())) unlinkSync(keyFile()); + const response = await httpFetch(`${AUTH_SERVER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refreshToken!, client_id: tokens.clientId! }), + timeoutMs: 20_000, idempotent: false, redirect: "error", + }); + if (!response.ok) return undefined; + next = await response.json(); + if (typeof next.access_token !== "string" || !next.access_token) return undefined; + } catch { return undefined; } + try { + writeTokens(makeTokens(next.access_token, + typeof next.refresh_token === "string" && next.refresh_token ? next.refresh_token : tokens.refreshToken, + tokenExpiresAt(next.expires_in), tokens.clientId), tokens.accessToken); } catch { - /* the conf delete below still applies */ + throw new Error("Clerk refreshed the session, but the new credentials could not be saved. Restore credential-directory write access and sign in again."); + } + return next.access_token; +} + +export function tokenExpiresAt(expiresIn?: number): number | undefined { + return typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn >= 0 + ? Date.now() + expiresIn * 1000 : undefined; +} + +async function revokeTokens(tokens?: StoredTokens): Promise { + if (!tokens?.refreshToken || tokens.accessToken.startsWith("sk-")) return false; + if (!tokens.clientId) throw new Error("Stored OAuth client ID is missing. Credentials retained; server revocation could not be completed."); + let response: Response; + try { + response = await httpFetch(`${AUTH_SERVER}/oauth/token/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: "refresh_token", client_id: tokens.clientId }), + timeoutMs: 20_000, idempotent: false, redirect: "error", + }); + } catch { throw new Error("Could not reach Clerk to revoke the OAuth session. Credentials retained; retry the command."); } + if (!response.ok) throw new Error(`OAuth revocation failed (HTTP ${response.status}). Credentials retained; retry the command.`); + return true; +} + +// A replacement is journaled before revoking the old grant. Failed cleanup +// never discards the only copy of a newly issued, non-expiring refresh token. +function readPending(): StoredTokens[] { + let pending: StoredTokens[]; + try { pending = JSON.parse(readFileSync(pendingFile(), "utf-8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + pending = []; + } + // Import lock-acquisition failures atomically into the ordinary cleanup queue. + for (const name of readdirSync(aisaDir())) { + if (!/^\.pending-login-[0-9a-f-]+\.json$/.test(name)) continue; + const path = join(aisaDir(), name); + const recovered: StoredTokens = JSON.parse(readFileSync(path, "utf-8")); + if (!pending.some((entry) => sameGrant(entry, recovered))) pending.push(recovered); + writeJson(pendingFile(), pending); + removeFile(path); + } + return pending; +} + +function sameGrant(a: StoredTokens | undefined, b: StoredTokens): boolean { + return !!a && a.clientId === b.clientId && a.refreshToken === b.refreshToken && + (b.refreshToken ? true : a.accessToken === b.accessToken); +} + +async function cleanupPending(keep?: StoredTokens): Promise { + const current = readTokenFile(); + const pending = readPending(); + for (let i = pending.length - 1; i >= 0; i--) { + if (keep && sameGrant(keep, pending[i])) continue; + if (!sameGrant(current, pending[i])) await revokeTokens(pending[i]); + pending.splice(i, 1); + writeJson(pendingFile(), pending); } + if (!pending.length) removeFile(pendingFile()); +} + +/** Replace a login only after revoking the previous OAuth grant. */ +export async function replaceTokens(accessToken: string, refreshToken?: string, expiresAt?: number, clientId?: string): Promise { + const next = makeTokens(accessToken, refreshToken, expiresAt, clientId); + let journaled = false; + try { + await withTokenLock(async () => { + const pending = readPending(); + if (!pending.some((entry) => sameGrant(entry, next))) pending.push(next); + writeJson(pendingFile(), pending); + journaled = true; + await cleanupPending(next); + const previous = readTokens(); + if (previous?.refreshToken && previous.refreshToken === next.refreshToken && previous.clientId === next.clientId) { + writeTokens(next); + removeFile(pendingFile()); + return; + } + await revokeTokens(previous); + writeTokens(next); + removeFile(pendingFile()); + }); + } catch (error) { + if (!journaled && next.refreshToken) { + // Clerk already issued this grant before we attempted to acquire the lock. + // Preserve it independently so later login/logout can revoke it. + try { + writeIndependentJson(join(aisaDir(), `.pending-login-${randomUUID()}.json`), next); + } catch { + try { await revokeTokens(next); } + catch { throw new Error("Login failed; the newly issued OAuth grant could neither be saved for cleanup nor revoked. Check Clerk authorization management."); } + } + } + throw error; + } +} + +export async function revokeAndClearTokens(): Promise { + return withTokenLock(async () => { + await cleanupPending(); + const revoked = await revokeTokens(readTokens()); + clearTokensLocked(); + return revoked; + }); +} + +export async function clearTokens(): Promise { + await withTokenLock(async () => { + await cleanupPending(); + clearTokensLocked(); + }); +} + +function clearTokensLocked(): void { + for (const path of [tokenFile(), keyFile(), rotationFile()]) removeFile(path); + config.delete("tokens"); config.delete("apiKey"); } export function getKeySource(): "env" | "config" | "none" { if (process.env[ENV_VAR_NAME]) return "env"; - if (readKeyFile() || config.get("apiKey")) return "config"; - return "none"; + return readTokenFile() || readLegacyKey() ? "config" : "none"; } export function getConfig(key: string): unknown { diff --git a/src/constants.ts b/src/constants.ts index e197a30..aaeba20 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -export const VERSION = "0.5.2"; +export const VERSION = "0.6.0"; /** Root of the platform. Per-surface bases are derived in api.ts#resolveBases. */ export const BASE_URL = "https://api.aisa.one"; export const ENV_VAR_NAME = "AISA_API_KEY"; @@ -162,3 +162,8 @@ export const MODEL_PROVIDERS = [ ] as const; export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; + +export const AUTH_SERVER = "https://clerk.aisa.one"; +export const AUTH_SETUP_GUIDANCE = + `Run "aisa login" to sign in with OAuth. For CI, set ${ENV_VAR_NAME} or use "aisa login --key ".`; +export const MISSING_API_KEY_GUIDANCE = `Not authenticated. ${AUTH_SETUP_GUIDANCE}`; diff --git a/src/index.ts b/src/index.ts index fded06c..6faa0b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,13 +99,13 @@ program program .command("logout") - .description("Remove stored API key") - .action(logoutAction); + .description("Revoke OAuth refresh token and remove stored credentials") + .action(wrap(logoutAction)); program .command("whoami") .description("Show authentication status") - .action(whoamiAction); + .action(wrap(whoamiAction)); // ── Account ── @@ -301,7 +301,7 @@ mcp .option("--agent ", "Target agent: cursor, claude-desktop, all") .option("--all", "Configure every live server, not just the default set") .option("--yes", "Write the files. Without it, print what would change and stop") - .action(mcpSetupAction); + .action(wrap(mcpSetupAction)); mcp .command("status") diff --git a/src/router.ts b/src/router.ts index f9b97f1..2a6ed27 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ import { getConfig } from "./config.js"; -import { httpFetch } from "./utils/http.js"; +import { authenticatedFetch } from "./utils/auth-http.js"; /** * Deployed Tool Router origin (nginx / tools.aisa.one). Paths already include @@ -47,7 +47,7 @@ export interface RouterRequest { /** Raw application JSON. Sent unchanged so numeric tokens survive. */ body: string; /** Bearer credential without the "Bearer " prefix, if any. */ - apiKey?: string; + accessToken?: string; } export interface RouterHttpResult { @@ -56,8 +56,8 @@ export interface RouterHttpResult { } /** - * POST one Router operation. Never retries: quote and use are not safe to - * replay, and search/schema must not hide a failed attempt. + * POST one Router operation. Only a 401 permits one authentication retry; quote and use are not safe to + * replay after a transport or server failure. */ export async function routerPost(request: RouterRequest): Promise { const url = `${resolveRouterBase()}${ROUTER_PATHS[request.operation]}`; @@ -65,11 +65,11 @@ export async function routerPost(request: RouterRequest): Promise { + const response = await httpFetch(url, options); + if (response.status !== 401) return response; + const headers = new Headers(options.headers); + const authorization = headers.get("Authorization"); + if (!authorization?.startsWith("Bearer ")) return response; + const token = await refreshAccessToken(authorization.slice(7)); + if (!token) return response; + await response.body?.cancel(); + headers.set("Authorization", `Bearer ${token}`); + return httpFetch(url, { ...options, headers: Object.fromEntries(headers.entries()) }); +} diff --git a/tests/__snapshots__/connect-snapshot.test.ts.snap b/tests/__snapshots__/connect-snapshot.test.ts.snap index b0cf1eb..0da469e 100644 --- a/tests/__snapshots__/connect-snapshot.test.ts.snap +++ b/tests/__snapshots__/connect-snapshot.test.ts.snap @@ -524,7 +524,7 @@ exports[`T2 page — byte-exact snapshots > done view 1`] = `
-
Your configured AIsa API key is written into each entry — no sign-in needed.
+
Your current AIsa credential is written into each entry — no sign-in needed.
Step 6 of 6

Almost there…

The results appear here as soon as the run finishes.

@@ -2005,7 +2005,7 @@ exports[`T2 page — byte-exact snapshots > nothing detected — every card is a
-
Your configured AIsa API key is written into each entry — no sign-in needed.
+
Your current AIsa credential is written into each entry — no sign-in needed.
Step 6 of 6

Almost there…

The results appear here as soon as the run finishes.

@@ -3486,7 +3486,7 @@ exports[`T2 page — byte-exact snapshots > start view, installers unavailable
-
Your configured AIsa API key is written into each entry — no sign-in needed.
+
Your current AIsa credential is written into each entry — no sign-in needed.
Step 6 of 6

Almost there…

The results appear here as soon as the run finishes.

@@ -4979,7 +4979,7 @@ exports[`T2 page — byte-exact snapshots > start view, key already configured 1
-
Your configured AIsa API key is written into each entry — no sign-in needed.
+
Your current AIsa credential is written into each entry — no sign-in needed.
Step 6 of 6

Almost there…

The results appear here as soon as the run finishes.

@@ -6472,7 +6472,7 @@ exports[`T2 page — byte-exact snapshots > start view, no key — the sign-in s
-
One sign-in, nothing to paste. Your browser opens the AIsa approval once; it issues a long-lived key for this machine, and every server and model is configured with it.
+
One sign-in, nothing to paste. Your browser opens the AIsa approval once; every server and model is configured with the current access token. Reconnect when that token expires.
Step 6 of 6

Almost there…

The results appear here as soon as the run finishes.

diff --git a/tests/auth-errors.cli.test.ts b/tests/auth-errors.cli.test.ts new file mode 100644 index 0000000..0ddf4cf --- /dev/null +++ b/tests/auth-errors.cli.test.ts @@ -0,0 +1,43 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeAll, expect, it } from "vitest"; + +const cli = resolve("dist/index.js"); +const homes: string[] = []; +beforeAll(() => { if (!existsSync(cli)) execFileSync("npx", ["tsc"], { stdio: "pipe" }); }); +afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); }); + +it.each(["logout", "whoami", "mcp"])("reports %s credential failures without an unhandled rejection", (command) => { + const home = mkdtempSync(join(tmpdir(), "aisa-auth-errors-")); + homes.push(home); + mkdirSync(join(home, ".aisa")); + mkdirSync(join(home, ".cursor")); + const tokenPath = join(home, ".aisa/tokens.json"); + // A real command subprocess, isolated credentials, and no external network. + const stored = JSON.stringify(command === "logout" + ? { accessToken: "access", refreshToken: "refresh", clientId: "client" } + : { accessToken: "" }); + writeFileSync(tokenPath, stored, { mode: 0o600 }); + const args = command === "mcp" ? ["mcp", "setup", "--agent", "cursor"] : [command]; + const env = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, "config"), PATH: "", NO_COLOR: "1" }; + delete env.AISA_API_KEY; + const bootstrap = join(home, "run.mjs"); + writeFileSync(bootstrap, ` + globalThis.fetch = async (url) => { + if (String(url).endsWith('/oauth/token/revoke')) return new Response('', { status: 503 }); + return Response.json({ servers: [{ slug: 'web-search', status: 'live', transport: { endpoint: 'https://example.test/mcp' } }] }); + }; + await import(${JSON.stringify(pathToFileURL(cli).href)}); + `); + const result = spawnSync(process.execPath, [bootstrap, ...args], { env, encoding: "utf8", timeout: 10_000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + const message = command === "logout" + ? "OAuth revocation failed (HTTP 503). Credentials retained; retry the command." + : "Invalid token file. Restore it before changing credentials."; + expect(result.stderr.trim()).toBe(`Error: ${message}`); + expect(readFileSync(tokenPath, "utf8")).toBe(stored); +}); diff --git a/tests/auth-refresh.test.ts b/tests/auth-refresh.test.ts new file mode 100644 index 0000000..fb2db3b --- /dev/null +++ b/tests/auth-refresh.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { apiRequest, apiRequestRaw } from "../src/api.js"; +import { routerPost } from "../src/router.js"; +import { refreshAccessToken } from "../src/config.js"; + +vi.mock("../src/config.js", () => ({ + getConfig: () => "", + refreshAccessToken: vi.fn(), +})); + +afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks(); }); + +const requests = { + api: () => apiRequest("old", "test", { method: "POST", body: { x: 1 } }), + raw: () => apiRequestRaw("old", "test", { method: "POST", body: { x: 1 } }), + router: () => routerPost({ accessToken: "old", operation: "call", body: '{"x":1}' }), +}; + +describe.each(Object.entries(requests))("%s authentication retry", (_name, request) => { + it("refreshes once and preserves request body and options", async () => { + const fetch = vi.fn().mockResolvedValueOnce(new Response("unauthorized", { status: 401 })) + .mockResolvedValueOnce(Response.json({ ok: true })); + vi.stubGlobal("fetch", fetch); + vi.mocked(refreshAccessToken).mockResolvedValue("new"); + await request(); + expect(refreshAccessToken).toHaveBeenCalledExactlyOnceWith("old"); + expect(fetch).toHaveBeenCalledTimes(2); + const [firstUrl, first] = fetch.mock.calls[0]; + const [secondUrl, second] = fetch.mock.calls[1]; + expect(secondUrl).toBe(firstUrl); + expect(second.body).toBe(first.body); + expect(second.method).toBe(first.method); + expect(second.redirect).toBe(first.redirect); + expect(new Headers(second.headers).get("Authorization")).toBe("Bearer new"); + }); + + it("returns the original 401 if refresh is unavailable or fails", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response('"denied"', { status: 401 }))); + vi.mocked(refreshAccessToken).mockResolvedValue(undefined); + const result = await request(); + expect(fetch).toHaveBeenCalledTimes(1); + if ("status" in result) expect(result.status).toBe(401); + else expect(result).toMatchObject({ success: false, error: expect.stringContaining("401") }); + }); + + it("does not loop on a second 401", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response('"denied"', { status: 401 }))); + vi.mocked(refreshAccessToken).mockResolvedValue("new"); + await request(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + }); +}); + +it("does not attach local credentials to anonymous Router requests", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("denied", { status: 401 }))); + await routerPost({ operation: "search", body: "{}" }); + expect(refreshAccessToken).not.toHaveBeenCalled(); +}); diff --git a/tests/config-concurrency.test.ts b/tests/config-concurrency.test.ts new file mode 100644 index 0000000..2e1a31e --- /dev/null +++ b/tests/config-concurrency.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { createServer, type ServerResponse } from "node:http"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const configUrl = pathToFileURL(resolve("dist/config.js")).href; +const homes: string[] = []; +const children: ChildProcess[] = []; +beforeAll(() => { if (!existsSync(resolve("dist/config.js"))) execFileSync("npx", ["tsc"], { stdio: "pipe" }); }); +afterEach(() => { + for (const child of children.splice(0)) child.kill(); + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +function seed() { + const home = mkdtempSync(join(tmpdir(), "aisa-concurrency-")); homes.push(home); + mkdirSync(join(home, ".aisa")); + writeFileSync(join(home, ".aisa/tokens.json"), JSON.stringify({ accessToken: "old", refreshToken: "refresh", expiresAt: 0, clientId: "client" }), { mode: 0o600 }); + return home; +} +function saved(home: string) { return JSON.parse(readFileSync(join(home, ".aisa/tokens.json"), "utf8")); } + +function run(home: string, base: string, action: string) { + const env = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, "config") }; + delete env.AISA_API_KEY; + const child = spawn(process.execPath, ["--input-type=module", "-e", ` + import * as config from ${JSON.stringify(configUrl)}; + const realFetch = globalThis.fetch; + globalThis.fetch = (url, options) => realFetch(${JSON.stringify(base)} + new URL(url).pathname, options); + process.send({ type: "started" }); + try { const result = await (${action}); process.send({ type: "result", result }); } + catch (e) { process.send({ type: "error", error: e.message }); process.exitCode = 1; } + `], { env, stdio: ["ignore", "ignore", "pipe", "ipc"] }); + children.push(child); + let stderr = ""; + child.stderr!.on("data", (data) => { stderr += data; }); + let start!: () => void; + const started = new Promise((resolve) => { start = resolve; }); + let answer: unknown; + const result = new Promise((resolve, reject) => { + child.on("message", (message: any) => { + if (message.type === "started") start(); + if (message.type === "result") answer = message.result; + if (message.type === "error") reject(new Error(message.error)); + }); + child.on("error", reject); + child.on("exit", (code) => { if (code === 0) resolve(answer); else reject(new Error(stderr || `child exit ${code}`)); }); + }); + return { child, started, result }; +} + +async function provider() { + const calls: { path: string; body: URLSearchParams }[] = []; + let release!: () => void; + let first!: () => void; + const firstRequest = new Promise((resolve) => { first = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const server = createServer(async (req, res: ServerResponse) => { + let raw = ""; for await (const chunk of req) raw += chunk; + calls.push({ path: req.url!, body: new URLSearchParams(raw) }); + if (calls.length === 1) { first(); await gate; } + if (req.url === "/oauth/token") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ access_token: "new", refresh_token: "rotated", expires_in: 3600 })); + } else { res.writeHead(200); res.end(); } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + return { base: `http://127.0.0.1:${address.port}`, calls, firstRequest, release, + close: () => { release(); server.closeAllConnections(); return new Promise((resolve) => server.close(() => resolve())); } }; +} + +describe("credential coordination across real CLI processes", () => { + it("refreshes once and shares the new token with a concurrent 401 retry", async () => { + const home = seed(), api = await provider(); + try { + const first = run(home, api.base, "config.getAccessToken()"); + await api.firstRequest; + const second = run(home, api.base, 'config.refreshAccessToken("old")'); + await second.started; + api.release(); + expect(await Promise.all([first.result, second.result])).toEqual(["new", "new"]); + expect(api.calls).toHaveLength(1); + expect(saved(home).refreshToken).toBe("rotated"); + } finally { await api.close(); } + }); + + it.each(["logout", "replace"])("serializes refresh with %s and revokes the rotated credential", async (action) => { + const home = seed(), api = await provider(); + try { + const refresh = run(home, api.base, "config.getAccessToken()"); + await api.firstRequest; + const change = run(home, api.base, action === "logout" ? "config.revokeAndClearTokens()" : 'config.replaceTokens("sk-replacement")'); + await change.started; + api.release(); + await Promise.all([refresh.result, change.result]); + expect(api.calls.map((c) => c.path)).toEqual(["/oauth/token", "/oauth/token/revoke"]); + expect(api.calls[1].body.get("token")).toBe("rotated"); + if (action === "logout") expect(existsSync(join(home, ".aisa/tokens.json"))).toBe(false); + else expect(saved(home)).toEqual({ accessToken: "sk-replacement" }); + } finally { await api.close(); } + }); + + it("does not let a waiting refresh resurrect a logged-out session", async () => { + const home = seed(), api = await provider(); + try { + const logout = run(home, api.base, "config.revokeAndClearTokens()"); + await api.firstRequest; + const refresh = run(home, api.base, 'config.refreshAccessToken("old")'); + await refresh.started; + api.release(); + expect(await logout.result).toBe(true); + expect(await refresh.result).toBeUndefined(); + expect(api.calls).toHaveLength(1); + expect(existsSync(join(home, ".aisa/tokens.json"))).toBe(false); + } finally { await api.close(); } + }); + + it("serializes two new logins and retires both superseded grants", async () => { + const home = seed(), api = await provider(); + try { + const first = run(home, api.base, 'config.replaceTokens("first", "first-refresh", 9999999999999, "first-client")'); + await api.firstRequest; + const second = run(home, api.base, 'config.replaceTokens("second", "second-refresh", 9999999999999, "second-client")'); + await second.started; + api.release(); + await Promise.all([first.result, second.result]); + expect(api.calls.map((call) => call.body.get("token"))).toEqual(["refresh", "first-refresh"]); + expect(saved(home).refreshToken).toBe("second-refresh"); + } finally { await api.close(); } + }); + + it("recovers a stale lock left by a terminated process", async () => { + const home = seed(), api = await provider(); + try { + const lock = join(home, ".aisa/tokens.json.lock"); + mkdirSync(lock); + const past = new Date(Date.now() - 60_000); utimesSync(lock, past, past); + const refresh = run(home, api.base, "config.getAccessToken()"); + await api.firstRequest; api.release(); + expect(await refresh.result).toBe("new"); + expect(existsSync(lock)).toBe(false); + } finally { await api.close(); } + }); +}); diff --git a/tests/config-key.test.ts b/tests/config-key.test.ts index fdeccd6..573b3ef 100644 --- a/tests/config-key.test.ts +++ b/tests/config-key.test.ts @@ -1,15 +1,9 @@ +import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -/** - * ~/.aisa/key is the single source of truth for the credential. These tests - * pin the three behaviours the rest of the platform depends on: the file is - * written 0600, it wins over the legacy conf store, and a legacy-only key - * migrates to the file on first read. - */ - let home: string; vi.mock("node:os", async (orig) => { @@ -43,9 +37,11 @@ vi.mock("conf", () => ({ }, })); -const { getApiKey, setApiKey, clearApiKey } = await import("../src/config.js"); +const { getAccessToken, storeTokens, clearTokens, refreshAccessToken, getKeySource, revokeAndClearTokens, replaceTokens } = await import("../src/config.js"); const keyPath = () => join(home, ".aisa", "key"); +const tokenPath = () => join(home, ".aisa", "tokens.json"); +const saved = () => JSON.parse(readFileSync(tokenPath(), "utf-8")); beforeEach(() => { home = mkdtempSync(join(tmpdir(), "aisa-key-")); @@ -54,44 +50,104 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); rmSync(home, { recursive: true, force: true }); }); -describe("~/.aisa/key", () => { - it("setApiKey writes the file with 0600 and getApiKey reads it back", () => { - setApiKey("sk-file-key"); - expect(readFileSync(keyPath(), "utf-8").trim()).toBe("sk-file-key"); +describe("token storage and refresh", () => { + it("stores tokens with 0600 and write-only legacy mirrors", async () => { + await storeTokens("access", "refresh", 123, "client"); + expect(saved()).toEqual({ accessToken: "access", refreshToken: "refresh", expiresAt: 123, clientId: "client" }); + expect(statSync(tokenPath()).mode & 0o777).toBe(0o600); expect(statSync(keyPath()).mode & 0o777).toBe(0o600); - expect(getApiKey()).toBe("sk-file-key"); + expect(confStore.apiKey).toBe("access"); + expect(confStore.tokens).toEqual(saved()); + expect(getKeySource()).toBe("config"); }); - it("env var wins over the file", () => { - setApiKey("sk-file-key"); - process.env.AISA_API_KEY = "sk-env-key"; - expect(getApiKey()).toBe("sk-env-key"); - delete process.env.AISA_API_KEY; + it("env overrides expired OAuth tokens and never refreshes", async () => { + await storeTokens("access", "refresh", 0, "client"); + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + process.env.AISA_API_KEY = "env-token"; + expect(await getAccessToken()).toBe("env-token"); + expect(await refreshAccessToken()).toBeUndefined(); + expect(getKeySource()).toBe("env"); + expect(fetch).not.toHaveBeenCalled(); }); - it("the file wins over the legacy conf store", () => { - confStore.apiKey = "sk-legacy"; + it("migrates the legacy file, never conf.apiKey", async () => { + confStore.apiKey = "ignored"; + expect(await getAccessToken()).toBeUndefined(); + expect(getKeySource()).toBe("none"); mkdirSync(join(home, ".aisa"), { recursive: true }); - writeFileSync(keyPath(), "sk-file-key\n"); - expect(getApiKey()).toBe("sk-file-key"); + writeFileSync(keyPath(), "sk-legacy\n"); + expect(await getAccessToken()).toBe("sk-legacy"); + expect(saved()).toEqual({ accessToken: "sk-legacy" }); }); - it("a legacy-only key migrates to the file on first read", () => { - confStore.apiKey = "sk-legacy"; + it("prefers tokens.json and clears all credential stores", async () => { + await storeTokens("access"); + writeFileSync(keyPath(), "sk-stale"); + expect(await getAccessToken()).toBe("access"); + await clearTokens(); + expect(existsSync(tokenPath())).toBe(false); expect(existsSync(keyPath())).toBe(false); - expect(getApiKey()).toBe("sk-legacy"); - expect(readFileSync(keyPath(), "utf-8").trim()).toBe("sk-legacy"); + expect(confStore.apiKey).toBeUndefined(); + expect(confStore.tokens).toBeUndefined(); + expect(await getAccessToken()).toBeUndefined(); }); - it("clearApiKey removes both the file and the legacy entry", () => { - setApiKey("sk-file-key"); - clearApiKey(); - expect(existsSync(keyPath())).toBe(false); - expect(getApiKey()).toBeUndefined(); + it("static login replaces OAuth metadata and never refreshes", async () => { + await storeTokens("access", "refresh", 0, "client"); + await storeTokens("static-key"); + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + expect(saved()).toEqual({ accessToken: "static-key" }); + expect(await getAccessToken()).toBe("static-key"); + expect(await refreshAccessToken()).toBeUndefined(); + await storeTokens("sk-static", "refresh", 0, "client"); + expect(await refreshAccessToken()).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refreshes near expiry once for concurrent readers and rotates tokens", async () => { + await storeTokens("old", "refresh", Date.now() + 30_000, "client"); + const fetch = vi.fn(async () => Response.json({ access_token: "new", refresh_token: "rotated", expires_in: 3600 })); + vi.stubGlobal("fetch", fetch); + expect(await Promise.all([getAccessToken(), getAccessToken(), refreshAccessToken("old")])).toEqual(["new", "new", "new"]); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, options] = fetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://clerk.aisa.one/oauth/token"); + expect(new URLSearchParams(options.body as URLSearchParams).get("client_id")).toBe("client"); + expect(new URLSearchParams(options.body as URLSearchParams).get("refresh_token")).toBe("refresh"); + expect(new URLSearchParams(options.body as URLSearchParams).get("grant_type")).toBe("refresh_token"); + expect(saved().refreshToken).toBe("rotated"); + expect(saved().expiresAt).toBeGreaterThan(Date.now() + 3_500_000); + expect(await getAccessToken()).toBe("new"); + expect(await refreshAccessToken("old")).toBe("new"); + expect(fetch).toHaveBeenCalledTimes(1); }); + + it("preserves refresh token when omitted and rejects unrelated credentials", async () => { + await storeTokens("old", "refresh", undefined, "client"); + const fetch = vi.fn(async () => Response.json({ access_token: "new" })); + vi.stubGlobal("fetch", fetch); + expect(await refreshAccessToken("unrelated")).toBeUndefined(); + expect(await refreshAccessToken("old")).toBe("new"); + expect(saved().refreshToken).toBe("refresh"); + }); + + it.each(["http", "network", "malformed"])("preserves credentials on %s refresh failure", async (kind) => { + await storeTokens("old", "refresh", 0, "client"); + vi.stubGlobal("fetch", vi.fn(async () => { + if (kind === "network") throw new Error("offline"); + return kind === "http" ? new Response("invalid_grant", { status: 400 }) : Response.json({}); + })); + expect(await getAccessToken()).toBe("old"); + expect(saved().refreshToken).toBe("refresh"); + }); + + }); /** @@ -121,3 +177,157 @@ describe("legacy conf store permissions", () => { confPath = ""; }); }); + + +describe("OAuth logout", () => { + it("revokes the stored refresh token before clearing every mirror, even with an env override", async () => { + await storeTokens("access", "refresh", 0, "client"); + process.env.AISA_API_KEY = "sk-env"; + const fetch = vi.fn(async (url: string, options: RequestInit) => { + expect(url).toBe("https://clerk.aisa.one/oauth/token/revoke"); + expect(saved().refreshToken).toBe("refresh"); + expect(Object.fromEntries(options.body as URLSearchParams)).toEqual({ token: "refresh", token_type_hint: "refresh_token", client_id: "client" }); + expect(options.redirect).toBe("error"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetch); + expect((await Promise.all([revokeAndClearTokens(), revokeAndClearTokens()])).sort()).toEqual([false, true]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(existsSync(tokenPath())).toBe(false); + expect(existsSync(keyPath())).toBe(false); + expect(confStore.apiKey).toBeUndefined(); + expect(confStore.tokens).toBeUndefined(); + expect(await getAccessToken()).toBe("sk-env"); + }); + + it.each(["http", "network"])("retains credentials after %s failure for a retry", async (kind) => { + await storeTokens("access", "refresh", 0, "client"); + vi.stubGlobal("fetch", vi.fn(async () => { + if (kind === "network") throw new Error("network secret"); + return new Response("upstream secret", { status: 500 }); + })); + await expect(revokeAndClearTokens()).rejects.toThrow("Credentials retained"); + expect(saved().refreshToken).toBe("refresh"); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 }))); + expect(await revokeAndClearTokens()).toBe(true); + }); + + it("only clears static keys locally", async () => { + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + await storeTokens("sk-static"); + expect(await revokeAndClearTokens()).toBe(false); + expect(await revokeAndClearTokens()).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("waits for in-flight rotation and revokes the newly issued refresh token", async () => { + await storeTokens("old", "refresh", 0, "client"); + let finish!: (response: Response) => void; + const fetch = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })) + .mockImplementationOnce(async (_url: string, options: RequestInit) => { + expect((options.body as URLSearchParams).get("token")).toBe("rotated"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetch); + const refreshing = refreshAccessToken(); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const logout = revokeAndClearTokens(); + finish(Response.json({ access_token: "new", refresh_token: "rotated" })); + await refreshing; + expect(await logout).toBe(true); + expect(existsSync(tokenPath())).toBe(false); + }); + +}); + + +describe("login replacement", () => { + it.each(["oauth", "static"])("revokes the old grant when replacing with %s", async (kind) => { + await storeTokens("old", "old-refresh", 0, "old-client"); + const fetch = vi.fn(async (_url: string, options: RequestInit) => { + expect(saved().accessToken).toBe("old"); + expect((options.body as URLSearchParams).get("token")).toBe("old-refresh"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetch); + if (kind === "oauth") await replaceTokens("new", "new-refresh", Date.now() + 3600000, "new-client"); + else await replaceTokens("sk-new"); + expect(saved().accessToken).toBe(kind === "oauth" ? "new" : "sk-new"); + expect(fetch).toHaveBeenCalledTimes(1); + expect(existsSync(join(home, ".aisa", ".pending-tokens.json"))).toBe(false); + }); + + it("retains both grants after failed revocation and cleans them on logout", async () => { + await storeTokens("old", "old-refresh", 0, "old-client"); + vi.stubGlobal("fetch", vi.fn(async () => new Response("failed", { status: 500 }))); + await expect(replaceTokens("new", "new-refresh", 0, "new-client")).rejects.toThrow("Credentials retained"); + expect(saved().accessToken).toBe("old"); + const pending = join(home, ".aisa", ".pending-tokens.json"); + expect(JSON.parse(readFileSync(pending, "utf8"))[0].refreshToken).toBe("new-refresh"); + expect(statSync(pending).mode & 0o777).toBe(0o600); + const revoked: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, options: RequestInit) => { + revoked.push((options.body as URLSearchParams).get("token")!); + return new Response(null, { status: 200 }); + })); + expect(await revokeAndClearTokens()).toBe(true); + expect(revoked).toEqual(["new-refresh", "old-refresh"]); + expect(existsSync(pending)).toBe(false); + expect(existsSync(tokenPath())).toBe(false); + }); +}); + + +it("retains every incoming grant when replacement cleanup repeatedly fails", async () => { + await storeTokens("old", "old-refresh", 0, "old-client"); + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 }))); + await expect(replaceTokens("first", "first-refresh", 0, "first-client")).rejects.toThrow(); + await expect(replaceTokens("second", "second-refresh", 0, "second-client")).rejects.toThrow(); + const pending = JSON.parse(readFileSync(join(home, ".aisa/.pending-tokens.json"), "utf8")); + expect(pending.map((entry: { refreshToken: string }) => entry.refreshToken)).toEqual(["first-refresh", "second-refresh"]); + expect(saved().accessToken).toBe("old"); +}); + + +describe("credential persistence failures", () => { + it("preserves newly issued grants when acquiring the credential lock fails", async () => { + await storeTokens("old", "old-refresh", 0, "old-client"); + const lock = vi.spyOn(lockfile, "lock").mockRejectedValue(new Error("lock timeout")); + await expect(replaceTokens("new", "new-refresh", 0, "new-client")).rejects.toThrow("lock timeout"); + const recovery = readdirSync(join(home, ".aisa")).filter((name) => name.startsWith(".pending-login-")); + expect(recovery).toHaveLength(1); + const recoveryPath = join(home, ".aisa", recovery[0]); + expect(JSON.parse(readFileSync(recoveryPath, "utf8")).refreshToken).toBe("new-refresh"); + expect(statSync(recoveryPath).mode & 0o777).toBe(0o600); + expect(saved().refreshToken).toBe("old-refresh"); + lock.mockRestore(); + const revoked: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url: string, options: RequestInit) => { + revoked.push((options.body as URLSearchParams).get("token")!); + return new Response(null, { status: 200 }); + })); + await revokeAndClearTokens(); + expect(revoked).toEqual(["new-refresh", "old-refresh"]); + expect(existsSync(recoveryPath)).toBe(false); + }); + + it("keeps rotated credentials when the auxiliary rotation file is unwritable", async () => { + await storeTokens("old", "old-refresh", 0, "client"); + mkdirSync(join(home, ".aisa/.token-rotation.json")); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ access_token: "new", refresh_token: "new-refresh", expires_in: 3600 }))); + expect(await getAccessToken()).toBe("new"); + expect(saved().refreshToken).toBe("new-refresh"); + expect(await getAccessToken()).toBe("new"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("reports primary credential persistence failure instead of returning the old token", async () => { + await storeTokens("old", "old-refresh", 0, "client"); + vi.stubGlobal("fetch", vi.fn(async () => { + rmSync(tokenPath()); + mkdirSync(tokenPath()); + return Response.json({ access_token: "new", refresh_token: "new-refresh", expires_in: 3600 }); + })); + await expect(getAccessToken()).rejects.toThrow("new credentials could not be saved"); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c6060b2..58c5bb3 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -113,7 +113,7 @@ A false success (exit 0 on a partial batch) is RED. - CLI must satisfy **all** of: - exit exactly **1** - empty stdout - - stderr contains the missing-key diagnostic (`No API key found`, `aisa login`, and `AISA_API_KEY`) + - stderr contains the missing-key diagnostic (`Not authenticated.`, `aisa login`, and `AISA_API_KEY`) - zero dispatch (no quote/execute POST) Any other failure (unknown command, unknown option, network error, 401 JSON on stdout, wrong exit, or a dispatch) is RED. An arbitrary nonzero error is not success. diff --git a/tests/e2e/harness.mjs b/tests/e2e/harness.mjs index 1cae09c..34523dc 100755 --- a/tests/e2e/harness.mjs +++ b/tests/e2e/harness.mjs @@ -26,7 +26,7 @@ const fixturesDir = join(here, "fixtures"); const args = parseArgs(process.argv.slice(2)); const snapshot = process.env.AISA_ROUTER_SNAPSHOT || ""; const routerRepo = process.env.AISA_ROUTER_REPO || ""; -const MISSING_KEY_DIAGNOSTIC = /No API key found[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY/; +const MISSING_KEY_DIAGNOSTIC = /Not authenticated\.[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY/; function parseArgs(argv) { const out = { cli: process.env.AISA_CLI || "", skipBuild: false }; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index bc8cb46..810b6ce 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -42,7 +42,7 @@ let home: string; let apiKey: string | undefined; vi.mock("../src/config.js", () => ({ - getApiKey: () => apiKey, + getAccessToken: async () => apiKey, })); function stubManifest(status = 200): ReturnType { diff --git a/tests/oauth-login.test.ts b/tests/oauth-login.test.ts new file mode 100644 index 0000000..4e65a12 --- /dev/null +++ b/tests/oauth-login.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { signInAndStoreTokens } from "../src/commands/oauth-login.js"; +import { replaceTokens } from "../src/config.js"; + +vi.mock("../src/config.js", async (original) => ({ + ...await original(), replaceTokens: vi.fn(), +})); +vi.mock("../src/utils/exec.js", () => ({ run: vi.fn(async () => ({})) })); + +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); }); + +it("requests offline access and stores tokens with the original client ID without minting a key", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const fetch = vi.fn().mockResolvedValueOnce(Response.json({ client_id: "client" })) + .mockResolvedValueOnce(Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 })); + vi.stubGlobal("fetch", fetch); + const before = Date.now(); + expect(await signInAndStoreTokens({ catcher: { redirectUri: "http://127.0.0.1/callback", wait: async () => "code" } })).toBe("access"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://clerk.aisa.one/oauth/register", "https://clerk.aisa.one/oauth/token", + ]); + const registration = JSON.parse(fetch.mock.calls[0][1].body); + expect(registration.grant_types).toContain("refresh_token"); + expect(registration.scope).toContain("offline_access"); + const [access, refresh, expiry, client] = vi.mocked(replaceTokens).mock.calls[0]; + expect([access, refresh, client]).toEqual(["access", "refresh", "client"]); + expect(expiry).toBeGreaterThanOrEqual(before + 3600_000); +}); + +it.each([undefined, "", " ", null, 123])("keeps the existing session when refresh_token is invalid: %s", async (refreshToken) => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const fetch = vi.fn().mockResolvedValueOnce(Response.json({ client_id: "client" })) + .mockResolvedValueOnce(Response.json({ access_token: "access", refresh_token: refreshToken, expires_in: 3600 })); + vi.stubGlobal("fetch", fetch); + await expect(signInAndStoreTokens({ catcher: { redirectUri: "http://127.0.0.1/callback", wait: async () => "code" } })) + .rejects.toThrow("Existing credentials were kept"); + expect(replaceTokens).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(2); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index cf6285b..8ed0b89 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -96,7 +96,7 @@ describe("routerPost", () => { const body = '{"calls":[{"call_id":"c1","tool":"t","arguments":{"n":9007199254740993}}]}'; stubFetch([new Response(body, { status: 200 })]); - const res = await routerPost({ operation: "quote", body, apiKey: "sk-test" }); + const res = await routerPost({ operation: "quote", body, accessToken: "sk-test" }); expect(calls[0].init.body).toBe(body); expect((calls[0].init.headers as Record).Authorization).toBe("Bearer sk-test"); expect(res.raw).toBe(body); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 6be3f5e..5b2d81d 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -6,10 +6,10 @@ vi.mock("../src/config.js", async (orig) => { const actual = await orig(); return { ...actual, - getApiKey: () => process.env.AISA_API_KEY, - requireApiKey: () => { + getAccessToken: async () => process.env.AISA_API_KEY, + requireAccessToken: async () => { const key = process.env.AISA_API_KEY; - if (!key) throw new CliError("No API key found", 1); + if (!key) throw new CliError("Not authenticated.", 1); return key; }, }; @@ -128,7 +128,7 @@ describe("tool router commands", () => { await expect(quoteAction({ input: req, json: true })).rejects.toMatchObject({ exitCode: 1, message: expect.stringMatching( - /No API key found[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY[\s\S]*Do not invent a business result/ + /Not authenticated.[\s\S]*Run "aisa login"[\s\S]*AISA_API_KEY[\s\S]*Do not invent a business result/ ), }); await expect(callAction({ input: req, json: true })).rejects.toMatchObject({ exitCode: 1 });