diff --git a/__tests__/utils/api.test.ts b/__tests__/utils/api.test.ts index 7f04b13..6de69eb 100644 --- a/__tests__/utils/api.test.ts +++ b/__tests__/utils/api.test.ts @@ -536,4 +536,25 @@ describe('ApiClient', () => { await expect(client.getOrganizations()).rejects.toThrow(/not yet implemented/); }); }); + describe('axios errors (error.response.status)', () => { + const axiosError = (status: number) => ({ message: `Request failed with status code ${status}`, response: { status } }); + + it('getApplications maps an axios 401 to the login message', async () => { + mockListApplications.mockRejectedValue(axiosError(401)); + const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1'); + await expect(client.getApplications()).rejects.toThrow(/Authentication expired/); + }); + + it('getEnvironments maps an axios 403 to access denied', async () => { + mockListEnvironments.mockRejectedValue(axiosError(403)); + const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1', 'app-1'); + await expect(client.getEnvironments()).rejects.toThrow(/Access denied/); + }); + + it('getEnvironmentMetrics maps an axios 401 to the login message', async () => { + mockGetEnvironmentMetrics.mockRejectedValue(axiosError(401)); + const client = new ApiClient('https://api.quantcdn.io', 'token', 'org-1', 'app-1'); + await expect(client.getEnvironmentMetrics({ environmentId: 'prod' })).rejects.toThrow(/Authentication expired/); + }); + }); }); diff --git a/__tests__/utils/context.test.ts b/__tests__/utils/context.test.ts index 2e04187..248a436 100644 --- a/__tests__/utils/context.test.ts +++ b/__tests__/utils/context.test.ts @@ -9,6 +9,10 @@ jest.unstable_mockModule('../../src/utils/config.js', () => ({ loadMultiPlatformConfig: mockLoadMulti, saveActivePlatformConfig: mockSaveActive, savePlatformUpdates: mockSaveNamed, + applyPlatformUpdates: mockSaveNamed, + CREDENTIALS_LOCK_FILE: '/nonexistent/credentials.lock', + CONFIG_FILE: '/nonexistent/credentials', + loadCredentialsLocked: mockLoadMulti, getActivePlatformConfig: async () => { const m = await mockLoadMulti(); const { platformInfo, ...auth } = m.platforms[m.activePlatform]; diff --git a/__tests__/utils/credentials-file.test.ts b/__tests__/utils/credentials-file.test.ts new file mode 100644 index 0000000..0a5f28c --- /dev/null +++ b/__tests__/utils/credentials-file.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, jest, beforeAll, afterEach, afterAll } from '@jest/globals'; +import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, promises as fsp } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Real credentials file in a temp config dir. +const home = mkdtempSync(join(tmpdir(), 'qc-credentials-')); +const configDir = join(home, '.quant'); +process.env.QUANT_CONFIG_DIR = configDir; +if (!process.env.QUANT_CONFIG_DIR.startsWith(home)) { + throw new Error('refusing to run: QUANT_CONFIG_DIR is not the temp dir'); +} +mkdirSync(configDir); + +let config: typeof import('../../src/utils/config.js'); + +beforeAll(async () => { + config = await import('../../src/utils/config.js'); +}); + +afterEach(() => { + jest.restoreAllMocks(); + rmSync(config.CONFIG_FILE, { force: true }); +}); +afterAll(() => rmSync(home, { recursive: true, force: true })); + +const info = (id: string) => ({ id, name: id, host: `https://${id}.example` }); +const twoPlatforms = () => JSON.stringify({ + activePlatform: 'quantcdn', + platforms: { + quantcdn: { token: 'a', host: 'https://quantcdn.example', platformInfo: info('quantcdn') }, + quantgov: { token: 'b', host: 'https://quantgov.example', platformInfo: info('quantgov') }, + }, +}); + +describe('credentials file save', () => { + it('keeps a symlinked credentials file as a symlink and updates its target', async () => { + const target = join(home, 'dotfiles-credentials'); + writeFileSync(target, twoPlatforms()); + symlinkSync(target, config.CONFIG_FILE); + + await config.savePlatformUpdates('quantcdn', { activeOrganization: 'acme' }); + + expect(lstatSync(config.CONFIG_FILE).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(target, 'utf-8')).platforms.quantcdn.activeOrganization).toBe('acme'); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(readdirSync(home).filter((f) => f.endsWith('.tmp'))).toEqual([]); + }); + + it('writes in place when the rename fails with EBUSY (single-file bind mount)', async () => { + writeFileSync(config.CONFIG_FILE, twoPlatforms()); + const rename = jest.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' })); + + await config.savePlatformUpdates('quantgov', { activeOrganization: 'gov-org' }); + + expect(rename).toHaveBeenCalledTimes(4); + const saved = JSON.parse(readFileSync(config.CONFIG_FILE, 'utf-8')); + expect(saved.platforms.quantgov.activeOrganization).toBe('gov-org'); + expect(saved.platforms.quantcdn.token).toBe('a'); + expect(statSync(config.CONFIG_FILE).mode & 0o777).toBe(0o600); + expect(readdirSync(configDir).filter((f) => f.endsWith('.tmp'))).toEqual([]); + }); + + it('refuses to save when the file cannot be parsed, and leaves it unchanged', async () => { + writeFileSync(config.CONFIG_FILE, '{not json'); + + await expect(config.savePlatformUpdates('quantcdn', { activeOrganization: 'x' })).rejects.toThrow(); + + expect(readFileSync(config.CONFIG_FILE, 'utf-8')).toBe('{not json'); + }); + + it('migrates an old single-platform file under the lock', async () => { + writeFileSync(config.CONFIG_FILE, JSON.stringify({ token: 'old', host: 'https://dashboard.quantcdn.io' })); + + const multi = await config.loadMultiPlatformConfig(); + + expect(multi.activePlatform).toBe('quantcdn'); + expect(JSON.parse(readFileSync(config.CONFIG_FILE, 'utf-8')).platforms.quantcdn.token).toBe('old'); + expect(readdirSync(configDir)).toEqual(['credentials']); + }); + + it('logout removes the file under the lock', async () => { + writeFileSync(config.CONFIG_FILE, twoPlatforms()); + await config.clearAuthConfig(); + expect(readdirSync(configDir)).toEqual([]); + }); +}); diff --git a/__tests__/utils/file-lock.test.ts b/__tests__/utils/file-lock.test.ts new file mode 100644 index 0000000..d81d7b7 --- /dev/null +++ b/__tests__/utils/file-lock.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, jest, afterEach, afterAll } from '@jest/globals'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, promises as fsp } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { withFileLock, LOCK_STALE_MS } from '../../src/utils/file-lock.js'; + +const dir = mkdtempSync(join(tmpdir(), 'qc-file-lock-')); +let n = 0; +const nextLock = () => join(dir, `credentials.${++n}.lock`); +const record = (time: number, nonce = 'other') => JSON.stringify({ nonce, pid: 1, time }); + +afterEach(() => jest.restoreAllMocks()); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +describe('withFileLock', () => { + it('writes a nonce record while held and removes the file after', async () => { + const lockFile = nextLock(); + const seen = await withFileLock(lockFile, async () => JSON.parse(readFileSync(lockFile, 'utf-8'))); + expect(typeof seen.nonce).toBe('string'); + expect(seen.pid).toBe(process.pid); + expect(existsSync(lockFile)).toBe(false); + }); + + it('runs one holder at a time', async () => { + const lockFile = nextLock(); + const events: string[] = []; + const work = (name: string) => withFileLock(lockFile, async () => { + events.push(`${name}-start`); + await new Promise((resolve) => setTimeout(resolve, 50)); + events.push(`${name}-end`); + }); + await Promise.all([work('a'), work('b')]); + // Either holder can go first, but they must not overlap. + const first = events[0].split('-')[0]; + const second = first === 'a' ? 'b' : 'a'; + expect(events).toEqual([`${first}-start`, `${first}-end`, `${second}-start`, `${second}-end`]); + }); + + it('takes over a lock whose holder timestamp is stale', async () => { + const lockFile = nextLock(); + writeFileSync(lockFile, record(Date.now() - LOCK_STALE_MS - 1000)); + expect(await withFileLock(lockFile, async () => 'ran')).toBe('ran'); + expect(existsSync(lockFile)).toBe(false); + }); + + it('waits for a lock whose holder timestamp is fresh, even with an old mtime', async () => { + const lockFile = nextLock(); + writeFileSync(lockFile, record(Date.now())); + const old = new Date(Date.now() - 10 * LOCK_STALE_MS); + await fsp.utimes(lockFile, old, old); + let ran = false; + const pending = withFileLock(lockFile, async () => { ran = true; }); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(ran).toBe(false); + rmSync(lockFile); + await pending; + expect(ran).toBe(true); + }); + + it('does not delete a lock that another process now owns, and verify() fails', async () => { + const lockFile = nextLock(); + const taken = record(Date.now(), 'someone-else'); + let verifyError: unknown; + await withFileLock(lockFile, async (lock) => { + writeFileSync(lockFile, taken); + verifyError = await lock.verify().catch((error) => error); + }); + expect(verifyError).toBeInstanceOf(Error); + expect(readFileSync(lockFile, 'utf-8')).toBe(taken); + }); + + it('removes the lock file when the record cannot be written', async () => { + const lockFile = nextLock(); + const realOpen = fsp.open.bind(fsp); + jest.spyOn(fsp, 'open').mockImplementationOnce(async (...args: any[]) => { + const handle: any = await (realOpen as any)(...args); + handle.writeFile = async () => { throw new Error('disk full'); }; + return handle; + }); + await expect(withFileLock(lockFile, async () => 'ran')).rejects.toThrow('disk full'); + expect(existsSync(lockFile)).toBe(false); + }); + + it('adds an exit handler while held and removes it on release', async () => { + const before = process.listenerCount('exit'); + const during = await withFileLock(nextLock(), async () => process.listenerCount('exit')); + expect(during).toBe(before + 1); + expect(process.listenerCount('exit')).toBe(before); + }); + + it('touch() writes a new timestamp, and fails when the lock was taken over', async () => { + const lockFile = nextLock(); + await withFileLock(lockFile, async (lock) => { + const before = JSON.parse(readFileSync(lockFile, 'utf-8')); + await new Promise((resolve) => setTimeout(resolve, 20)); + await lock.touch(); + const after = JSON.parse(readFileSync(lockFile, 'utf-8')); + expect(after.nonce).toBe(before.nonce); + expect(after.time).toBeGreaterThan(before.time); + writeFileSync(lockFile, record(Date.now(), 'someone-else')); + await expect(lock.touch()).rejects.toThrow(/Lost the lock/); + }); + }); +}); diff --git a/__tests__/utils/oauth-refresh-flow.test.ts b/__tests__/utils/oauth-refresh-flow.test.ts new file mode 100644 index 0000000..83c99b6 --- /dev/null +++ b/__tests__/utils/oauth-refresh-flow.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, jest, beforeAll, beforeEach, afterEach, afterAll } from '@jest/globals'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, promises as fsp } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createRequire } from 'module'; +import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +// Real credentials file, lock file and refresh code, in a temp config dir. +const home = mkdtempSync(join(tmpdir(), 'qc-oauth-refresh-')); +process.env.QUANT_CONFIG_DIR = join(home, '.quant'); +mkdirSync(process.env.QUANT_CONFIG_DIR); +if (!process.env.QUANT_CONFIG_DIR.startsWith(home)) { + throw new Error('refusing to run: QUANT_CONFIG_DIR is not the temp dir'); +} + +jest.unstable_mockModule('../../src/utils/project-config.js', () => ({ + getProjectConfig: () => ({}), +})); + +const HOST = 'https://dash.example.test'; +const DAY = 24 * 60 * 60 * 1000; + +let credFile: string; +let ctx: typeof import('../../src/utils/context.js'); +let ApiClient: typeof import('../../src/utils/api.js').ApiClient; + +beforeAll(async () => { + ({ CONFIG_FILE: credFile } = await import('../../src/utils/config.js')); + ctx = await import('../../src/utils/context.js'); + ({ ApiClient } = await import('../../src/utils/api.js')); + (await import('../../src/utils/token-refresh.js')).recovery.delayMs = 5; +}); + +afterAll(() => rmSync(home, { recursive: true, force: true })); + +interface Login { + token: string; + refreshToken?: string; + expiresAt?: string; +} + +function writeLogin(login: Login): void { + const platform = { ...login, host: HOST, activeOrganization: 'acme', platformInfo: { id: 'quantcdn', name: 'Q', host: HOST } }; + writeFileSync(credFile, JSON.stringify({ activePlatform: 'quantcdn', platforms: { quantcdn: platform } })); +} + +const stored = () => JSON.parse(readFileSync(credFile, 'utf-8')).platforms.quantcdn; + +const json = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + statusText: String(status), + json: async () => body, +}); + +const rotated = (n: number, extra: Record = {}) => + json(200, { access_token: `qc_new${n}`, refresh_token: `qcr_new${n}`, expires_in: 90 * 86400, token_type: 'Bearer', ...extra }); + +const fetchMock = jest.fn<(...args: any[]) => any>(); +const isTokenCall = (call: any[]) => String(call[0]).endsWith('/oauth/token'); +const tokenCalls = () => fetchMock.mock.calls.filter(isTokenCall); +const sentRefreshToken = (call: any[]) => new URLSearchParams(call[1].body).get('refresh_token'); + +// The SDK calls the CommonJS axios when no instance is passed (legacy logins). +const cjsAxios = createRequire(import.meta.url)('axios'); +const sdkAxios = cjsAxios.default ?? cjsAxios; + +const realFetch = global.fetch; +const realAdapter = axios.defaults.adapter; +const realSdkAdapter = sdkAxios.defaults.adapter; + +beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + global.fetch = fetchMock as any; +}); + +afterEach(() => { + global.fetch = realFetch; + axios.defaults.adapter = realAdapter; + sdkAxios.defaults.adapter = realSdkAdapter; + jest.restoreAllMocks(); +}); + +/** Routes the quant-client (axios) requests to respond(), and records the token sent. */ +function fakeApi(respond: (token: string) => number): string[] { + const tokens: string[] = []; + const adapter = async (config: InternalAxiosRequestConfig) => { + const token = String(config.headers.Authorization).replace('Bearer ', ''); + tokens.push(token); + const status = respond(token); + const response: AxiosResponse = { data: [{ name: 'app' }], status, statusText: String(status), headers: {}, config }; + if (status >= 400) { + throw new AxiosError(`Request failed with status code ${status}`, 'ERR_BAD_REQUEST', config, null, response); + } + return response; + }; + axios.defaults.adapter = adapter; + sdkAxios.defaults.adapter = adapter; + return tokens; +} + +describe('legacy login (no refresh token, no expiry)', () => { + it('is never refreshed, and a 401 is not retried', async () => { + writeLogin({ token: 'legacy-token' }); + const before = readFileSync(credFile, 'utf-8'); + const sent = fakeApi(() => 401); + + const auth = await ctx.getEffectiveAuth(); + const client = await ApiClient.create(); + await expect(client.getApplications()).rejects.toThrow(/Authentication expired/); + + expect(auth?.token).toBe('legacy-token'); + expect(sent).toEqual(['legacy-token']); + expect(fetchMock).not.toHaveBeenCalled(); + expect(readFileSync(credFile, 'utf-8')).toBe(before); + }); +}); + +describe('proactive refresh', () => { + it('refreshes a due token and saves the new pair atomically with mode 0600', async () => { + writeLogin({ token: 'qc_old1', refreshToken: 'qcr_old1', expiresAt: new Date(Date.now() + DAY).toISOString() }); + fetchMock.mockResolvedValue(rotated(1)); + + const auth = await ctx.getEffectiveAuth(); + + expect(auth?.token).toBe('qc_new1'); + expect(tokenCalls()).toHaveLength(1); + expect(sentRefreshToken(tokenCalls()[0])).toBe('qcr_old1'); + expect(stored()).toMatchObject({ token: 'qc_new1', refreshToken: 'qcr_new1', activeOrganization: 'acme' }); + expect(Date.parse(stored().expiresAt)).toBeGreaterThan(Date.now() + 89 * DAY); + expect(statSync(credFile).mode & 0o777).toBe(0o600); + expect(readdirSync(join(home, '.quant'))).toEqual(['credentials']); + }); + + it('does not refresh a token with more than 7 days left', async () => { + writeLogin({ token: 'qc_fresh', refreshToken: 'qcr_fresh', expiresAt: new Date(Date.now() + 30 * DAY).toISOString() }); + + expect((await ctx.getEffectiveAuth())?.token).toBe('qc_fresh'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the stored refresh token when the response has none', async () => { + writeLogin({ token: 'qc_old2', refreshToken: 'qcr_keep', expiresAt: new Date(Date.now() + DAY).toISOString() }); + fetchMock.mockResolvedValue(json(200, { access_token: 'qc_new2', expires_in: 90 * 86400, token_type: 'Bearer' })); + + await ctx.getEffectiveAuth(); + + expect(stored()).toMatchObject({ token: 'qc_new2', refreshToken: 'qcr_keep' }); + }); + + it('sends one refresh request when two refreshes run at the same time', async () => { + writeLogin({ token: 'qc_old3', refreshToken: 'qcr_old3', expiresAt: new Date(Date.now() - DAY).toISOString() }); + fetchMock.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + return rotated(3); + }); + + const [a, b] = await Promise.all([ctx.getEffectiveAuth(), ctx.getEffectiveAuth()]); + + expect(tokenCalls()).toHaveLength(1); + expect(a?.token).toBe('qc_new3'); + expect(b?.token).toBe('qc_new3'); + expect(stored().refreshToken).toBe('qcr_new3'); + expect(existsSync(`${credFile}.lock`)).toBe(false); + }); +}); + +describe('401 retry', () => { + const notDue = () => new Date(Date.now() + 60 * DAY).toISOString(); + + it('refreshes once and repeats the API request once', async () => { + writeLogin({ token: 'qc_old4', refreshToken: 'qcr_old4', expiresAt: notDue() }); + fetchMock.mockResolvedValue(rotated(4)); + const sent = fakeApi((token) => (token === 'qc_new4' ? 200 : 401)); + + const client = await ApiClient.create(); + const apps = await client.getApplications(); + + expect(apps).toEqual([{ name: 'app' }]); + expect(sent).toEqual(['qc_old4', 'qc_new4']); + expect(tokenCalls()).toHaveLength(1); + expect(stored().token).toBe('qc_new4'); + }); + + it('does not loop when the renewed token also gets 401', async () => { + writeLogin({ token: 'qc_old5', refreshToken: 'qcr_old5', expiresAt: notDue() }); + fetchMock.mockResolvedValue(rotated(5)); + const sent = fakeApi(() => 401); + + const client = await ApiClient.create(); + await expect(client.getApplications()).rejects.toThrow(/Authentication expired/); + + expect(sent).toEqual(['qc_old5', 'qc_new5']); + expect(tokenCalls()).toHaveLength(1); + }); + + it('stops renewing in a client after the renewed token is refused too', async () => { + writeLogin({ token: 'qc_old8', refreshToken: 'qcr_old8', expiresAt: notDue() }); + fetchMock.mockResolvedValue(rotated(8)); + const sent = fakeApi(() => 401); + + const client = await ApiClient.create(); + await expect(client.getApplications()).rejects.toThrow(/Authentication expired/); + await expect(client.getApplications()).rejects.toThrow(/Authentication expired/); + + expect(sent).toEqual(['qc_old8', 'qc_new8', 'qc_new8']); + expect(tokenCalls()).toHaveLength(1); + }); + + it('refreshes once and repeats the /api/oauth/user request once', async () => { + writeLogin({ token: 'qc_old6', refreshToken: 'qcr_old6', expiresAt: notDue() }); + fetchMock.mockImplementation(async (url: any, init: any) => { + if (String(url).endsWith('/oauth/token')) { + return rotated(6); + } + return init.headers.Authorization === 'Bearer qc_new6' ? json(200, { email: 'a@b.c' }) : json(401, {}); + }); + + const client = await ApiClient.create(); + + expect(await client.getUserInfo()).toEqual({ email: 'a@b.c' }); + expect(tokenCalls()).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +describe('save failure after a rotation', () => { + it('uses the new pair, refreshes once in this process, and reports the path', async () => { + writeLogin({ token: 'qc_old9', refreshToken: 'qcr_old9', expiresAt: new Date(Date.now() + DAY).toISOString() }); + fetchMock.mockResolvedValue(rotated(9)); + const write = jest.spyOn(fsp, 'writeFile').mockRejectedValue(Object.assign(new Error('disk full'), { code: 'ENOSPC' })); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const first = await ctx.getEffectiveAuth(); + const second = await ctx.getEffectiveAuth(); + + expect(first?.token).toBe('qc_new9'); + expect(second?.token).toBe('qc_new9'); + expect(tokenCalls()).toHaveLength(1); + expect(write).toHaveBeenCalledTimes(2); + expect(error).toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0][0])).toContain(`Could not save the refreshed login to ${credFile}`); + expect(String(error.mock.calls[0][0])).toContain('qc login'); + expect(stored().token).toBe('qc_old9'); + }); +}); + +describe('refresh token refused', () => { + it('tells the user to run qc login and keeps the credentials', async () => { + writeLogin({ token: 'qc_old7', refreshToken: 'qcr_dead', expiresAt: new Date(Date.now() + DAY).toISOString() }); + const before = readFileSync(credFile, 'utf-8'); + fetchMock.mockResolvedValue(json(400, { error: 'invalid_grant', error_description: 'Refresh token revoked' })); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const auth = await ctx.getEffectiveAuth(); + await ctx.getEffectiveAuth(); + + expect(auth?.token).toBe('qc_old7'); + expect(tokenCalls()).toHaveLength(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('Refresh token revoked'); + expect(String(warn.mock.calls[0][0])).toContain('qc login'); + expect(readFileSync(credFile, 'utf-8')).toBe(before); + }); +}); diff --git a/__tests__/utils/token-refresh.test.ts b/__tests__/utils/token-refresh.test.ts new file mode 100644 index 0000000..3f93f09 --- /dev/null +++ b/__tests__/utils/token-refresh.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, jest, beforeEach, afterEach, afterAll } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { forceRefresh, isRefreshDue, recovery, refreshIfDue, toAuthUpdates, REFRESH_WINDOW_MS } from '../../src/utils/token-refresh.js'; +import type { AuthConfig } from '../../src/types/auth.js'; + +const DAY = 24 * 60 * 60 * 1000; +const dir = mkdtempSync(join(tmpdir(), 'qc-refresh-unit-')); +const lockFile = join(dir, 'credentials.lock'); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +const HOST = 'https://dashboard.quantcdn.io'; + +const login = (msFromNow: number, extra: Partial = {}): AuthConfig => ({ + token: 'qc_old', + refreshToken: 'qcr_old', + expiresAt: new Date(Date.now() + msFromNow).toISOString(), + host: HOST, + ...extra, +}); + +const jsonResponse = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +describe('isRefreshDue', () => { + it('never refreshes a legacy login without refresh token or expiry', () => { + expect(isRefreshDue({ token: 't', host: HOST })).toBe(false); + expect(isRefreshDue({ token: 't', host: HOST, refreshToken: 'r' })).toBe(false); + expect(isRefreshDue({ token: 't', host: HOST, expiresAt: new Date().toISOString() })).toBe(false); + }); + + it('refreshes inside the window and after expiry, not before', () => { + expect(isRefreshDue(login(30 * DAY))).toBe(false); + expect(isRefreshDue(login(REFRESH_WINDOW_MS - DAY))).toBe(true); + expect(isRefreshDue(login(-DAY))).toBe(true); + }); +}); + +describe('toAuthUpdates', () => { + it('maps the token response and leaves expiry unset without expires_in', () => { + const now = Date.UTC(2026, 0, 1); + expect(toAuthUpdates({ access_token: 'a', refresh_token: 'r', expires_in: 60, token_type: 'Bearer' }, now)) + .toEqual({ token: 'a', refreshToken: 'r', expiresAt: new Date(now + 60000).toISOString() }); + expect(toAuthUpdates({ access_token: 'a', token_type: 'Bearer' }, now).expiresAt).toBeUndefined(); + }); + + it('leaves refreshToken out when the response has no refresh_token, so the stored one is kept', () => { + const updates = toAuthUpdates({ access_token: 'a', expires_in: 60, token_type: 'Bearer' }); + expect('refreshToken' in updates).toBe(false); + expect({ ...login(DAY), ...updates }.refreshToken).toBe('qcr_old'); + }); +}); + +describe('refreshIfDue', () => { + const fetchMock = jest.fn<(...args: any[]) => any>(); + const save = jest.fn<(...args: any[]) => any>(); + const reload = jest.fn<(...args: any[]) => any>(); + const realFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = fetchMock as any; + save.mockResolvedValue(undefined); + reload.mockImplementation(async () => login(DAY)); + Object.assign(recovery, { delayMs: 5, maxRetries: 2, windowMs: 240_000 }); + }); + + afterEach(() => { + global.fetch = realFetch; + jest.restoreAllMocks(); + }); + + it('does not call the portal when no refresh is due', async () => { + const auth = login(30 * DAY); + expect(await refreshIfDue(auth, { save, reload, lockFile, location: 'creds' })).toBe(auth); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never refreshes a legacy login, even after a 401', async () => { + const legacy: AuthConfig = { token: 'legacy', host: HOST }; + expect(await refreshIfDue(legacy, { save, reload, lockFile, location: 'creds' })).toBe(legacy); + expect(await forceRefresh(legacy, { save, reload, lockFile, location: 'creds' })).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + + it('exchanges the refresh token and saves the new pair', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { + access_token: 'qc_new', refresh_token: 'qcr_new', expires_in: 90 * 86400, token_type: 'Bearer', + })); + + const result = await refreshIfDue(login(DAY), { save, reload, lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_new'); + expect(result.refreshToken).toBe('qcr_new'); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ token: 'qc_new', refreshToken: 'qcr_new' })); + const [url, init] = fetchMock.mock.calls[0] as [string, any]; + expect(url).toBe(`${HOST}/oauth/token`); + const body = new URLSearchParams(init.body); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('client_id')).toBe('quant-cli'); + expect(body.get('refresh_token')).toBe('qcr_old'); + }); + + it('uses the credentials another process saved, and sends no request', async () => { + reload.mockResolvedValue(login(90 * DAY, { token: 'qc_peer', refreshToken: 'qcr_peer' })); + + const result = await refreshIfDue(login(DAY), { save, reload, lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_peer'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it('force-refreshes a token that is not yet due', async () => { + reload.mockResolvedValue(login(60 * DAY)); + fetchMock.mockResolvedValue(jsonResponse(200, { access_token: 'qc_new', refresh_token: 'qcr_new', expires_in: 60, token_type: 'Bearer' })); + + const result = await forceRefresh(login(60 * DAY), { save, reload, lockFile, location: 'creds' }); + + expect(result?.token).toBe('qc_new'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the stored login when the refresh fails', async () => { + fetchMock.mockRejectedValue(new Error('network down')); + const auth = login(DAY); + reload.mockResolvedValue(auth); + + expect(await refreshIfDue(auth, { save, reload, lockFile, location: 'creds' })).toBe(auth); + expect(save).not.toHaveBeenCalled(); + }); + + describe('unknown outcome recovery', () => { + const ok = (n: string) => jsonResponse(200, { access_token: `qc_${n}`, refresh_token: `qcr_${n}`, expires_in: 60, token_type: 'Bearer' }); + const sentTokens = () => fetchMock.mock.calls.map((c: any[]) => new URLSearchParams(c[1].body).get('refresh_token')); + + it('retries once with the same refresh token after a timeout, and saves the recovered pair', async () => { + fetchMock + .mockRejectedValueOnce(Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' })) + .mockResolvedValueOnce(ok('recovered')); + + const result = await refreshIfDue(login(DAY, { refreshToken: 'qcr_t1' }), { save, reload: async () => login(DAY, { refreshToken: 'qcr_t1' }), lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_recovered'); + expect(sentTokens()).toEqual(['qcr_t1', 'qcr_t1']); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ token: 'qc_recovered', refreshToken: 'qcr_recovered' })); + }); + + it('retries once after a 5xx', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(503, {})).mockResolvedValueOnce(ok('after5xx')); + + const result = await refreshIfDue(login(DAY), { save, reload, lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_after5xx'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries once after an unreadable 200, and keeps the login when the retry is refused', async () => { + fetchMock + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => { throw new Error('bad json'); } }) + .mockResolvedValueOnce(jsonResponse(400, { error: 'invalid_grant', error_description: 'not found' })); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const auth = login(DAY, { refreshToken: 'qcr_unreadable' }); + + const result = await refreshIfDue(auth, { save, reload: async () => auth, lockFile, location: 'creds' }); + + expect(result).toBe(auth); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(save).not.toHaveBeenCalled(); + }); + + it('sends at most two retries', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')); + const auth = login(DAY, { refreshToken: 'qcr_three' }); + + expect(await refreshIfDue(auth, { save, reload: async () => auth, lockFile, location: 'creds' })).toBe(auth); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(save).not.toHaveBeenCalled(); + }); + + it('sends no retry after the window since the first send has passed', async () => { + Object.assign(recovery, { delayMs: 100, windowMs: 150 }); + fetchMock.mockResolvedValue(jsonResponse(503, {})); + const auth = login(DAY, { refreshToken: 'qcr_window' }); + + await refreshIfDue(auth, { save, reload: async () => auth, lockFile, location: 'creds' }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('skips the retry when the stored token changed during the wait', async () => { + fetchMock.mockResolvedValue(jsonResponse(502, {})); + const auth = login(DAY, { refreshToken: 'qcr_changed' }); + const stored = [auth, login(90 * DAY, { token: 'qc_other', refreshToken: 'qcr_other' })]; + const reloadSeq = jest.fn<(...args: any[]) => any>() + .mockResolvedValueOnce(stored[0]) + .mockResolvedValue(stored[1]); + + const result = await refreshIfDue(auth, { save, reload: reloadSeq, lockFile, location: 'creds' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.token).toBe('qc_other'); + expect(result.refreshToken).toBe('qcr_other'); + }); + + it('treats "just rotated" as unknown: no warning, not refused, and the retry recovers', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const auth = login(DAY, { refreshToken: 'qcr_just' }); + const justRotated = () => jsonResponse(400, { + error: 'invalid_grant', + error_description: 'Refresh token was just rotated; reload your stored tokens.', + }); + fetchMock.mockResolvedValueOnce(justRotated()).mockResolvedValueOnce(ok('recovered2')); + + const result = await refreshIfDue(auth, { save, reload: async () => auth, lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_recovered2'); + expect(sentTokens()).toEqual(['qcr_just', 'qcr_just']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not mark a "just rotated" token as refused', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const auth = login(DAY, { refreshToken: 'qcr_just2' }); + fetchMock.mockResolvedValue(jsonResponse(400, { error: 'invalid_grant', error_description: 'Refresh token was just rotated' })); + const store = { save, reload: async () => auth, lockFile, location: 'creds' }; + + await refreshIfDue(auth, store); + fetchMock.mockClear(); + fetchMock.mockResolvedValue(ok('later')); + const later = await refreshIfDue(auth, store); + + expect(later.token).toBe('qc_later'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not retry after a definite 4xx', async () => { + fetchMock.mockResolvedValue(jsonResponse(400, { error: 'invalid_grant' })); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const auth = login(DAY, { refreshToken: 'qcr_definite' }); + + await refreshIfDue(auth, { save, reload: async () => auth, lockFile, location: 'creds' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('waits the recovery delay after the previous attempt ended', async () => { + recovery.delayMs = 100; + const starts: number[] = []; + const ends: number[] = []; + fetchMock.mockImplementation(async () => { + starts.push(Date.now()); + await new Promise((resolve) => setTimeout(resolve, 60)); + ends.push(Date.now()); + return starts.length < 3 ? jsonResponse(504, {}) : ok('late'); + }); + + const result = await refreshIfDue(login(DAY), { save, reload, lockFile, location: 'creds' }); + + expect(result.token).toBe('qc_late'); + expect(starts).toHaveLength(3); + expect(starts[1] - ends[0]).toBeGreaterThanOrEqual(95); + expect(starts[2] - ends[1]).toBeGreaterThanOrEqual(95); + }); + }); +}); diff --git a/__tests__/utils/whoami-expiry.test.ts b/__tests__/utils/whoami-expiry.test.ts new file mode 100644 index 0000000..af8da61 --- /dev/null +++ b/__tests__/utils/whoami-expiry.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from '@jest/globals'; +import { formatTimeLeft } from '../../src/commands/whoami.js'; + +const HOUR = 60 * 60 * 1000; + +describe('formatTimeLeft', () => { + it('shows days when more than 48 hours are left', () => { + expect(formatTimeLeft(90 * 24 * HOUR)).toBe('90 days'); + expect(formatTimeLeft(49 * HOUR)).toBe('2 days'); + }); + + it('shows expired when no time is left', () => { + expect(formatTimeLeft(0)).toBe('expired'); + expect(formatTimeLeft(-HOUR)).toBe('expired'); + }); + + it('shows hours and minutes for 48 hours or less', () => { + expect(formatTimeLeft(48 * HOUR)).toBe('48h 0m'); + expect(formatTimeLeft(5 * HOUR + 20 * 60 * 1000)).toBe('5h 20m'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 5dac4ac..f11868f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quantcdn/quant-cloud-cli", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quantcdn/quant-cloud-cli", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@quantcdn/quant-client": "^4.30.0", @@ -52,7 +52,7 @@ "typescript": "^5.2.2" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 2777b82..8f3ebd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quantcdn/quant-cloud-cli", - "version": "0.8.0", + "version": "0.9.0", "description": "Command-line interface for Quant Cloud Platform integration and management", "type": "module", "main": "dist/index.js", @@ -95,7 +95,7 @@ "picomatch": "^4.0.4" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "files": [ "dist/**/*", diff --git a/src/commands/login.ts b/src/commands/login.ts index 374d7f3..84950d5 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -374,9 +374,8 @@ async function exchangeCodeForToken(code: string, codeVerifier: string, host: st code_verifier: codeVerifier, }); - logger.debug('Token request details:'); - logger.debug(` URL: ${tokenUrl.toString()}`); - logger.debug(` Body: ${body.toString()}`); + // Do not log the body: it holds the authorization code and the PKCE verifier. + logger.debug(`Token request: ${tokenUrl.toString()}`); const response = await fetch(tokenUrl.toString(), { method: 'POST', diff --git a/src/commands/logout.ts b/src/commands/logout.ts index b02272b..7a59914 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,9 +1,8 @@ import { Command } from 'commander'; import inquirer from 'inquirer'; -import { unlink } from 'fs/promises'; import { existsSync } from 'fs'; import chalk from 'chalk'; -import { CONFIG_FILE } from '../utils/config.js'; +import { CONFIG_FILE, clearAuthConfig } from '../utils/config.js'; import { Logger } from '../utils/logger.js'; const logger = new Logger('Logout'); @@ -41,7 +40,7 @@ async function handleLogout() { } // Remove credentials file - await unlink(CONFIG_FILE); + await clearAuthConfig(); logger.info(`${chalk.green('✓')} Successfully logged out`); logger.info('Run `quant-cloud login` to authenticate again'); diff --git a/src/commands/org.ts b/src/commands/org.ts index 5bd1ff0..54034bd 100644 --- a/src/commands/org.ts +++ b/src/commands/org.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import { createSpinner } from '../utils/spinner.js'; import { getEffectiveAuth, saveEffectiveConfig } from '../utils/context.js'; +import { ApiClient } from '../utils/api.js'; import { UserInfo } from '../types/auth.js'; import { Logger } from '../utils/logger.js'; @@ -45,24 +46,14 @@ async function handleOrgList() { let userInfo: UserInfo; try { - const response = await fetch(`${auth.host}/api/oauth/user`, { - headers: { - 'Authorization': `Bearer ${auth.token}`, - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - spinner.fail('Failed to fetch organization data'); - logger.error('Failed to fetch organization data. Please re-authenticate with `quant-cloud login`.'); - return; - } - - userInfo = await response.json() as UserInfo; + // ApiClient renews the login once after a 401. + const client = await ApiClient.create(); + userInfo = await client.getUserInfo() as UserInfo; spinner.succeed('Loaded organizations'); } catch (error) { - spinner.fail('Failed to load organizations'); - throw error; + spinner.fail('Failed to fetch organization data'); + logger.error(error instanceof Error ? error.message : String(error)); + return; } if (!userInfo.organizations || userInfo.organizations.length === 0) { diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 25ac34e..752c9d9 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import { resolveEffectiveContext } from '../utils/context.js'; +import { resolveEffectiveContext, EffectiveContext } from '../utils/context.js'; import { Logger } from '../utils/logger.js'; const logger = new Logger('WhoAmI'); @@ -14,6 +14,59 @@ export function whoamiCommand(program: Command) { }); } +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** "12 days" when more than 48 hours are left, "5h 20m" when less, "expired" when none. */ +export function formatTimeLeft(ms: number): string { + if (ms <= 0) { + return 'expired'; + } + if (ms > 48 * HOUR_MS) { + return `${Math.floor(ms / DAY_MS)} days`; + } + const hours = Math.floor(ms / HOUR_MS); + const minutes = Math.floor((ms % HOUR_MS) / (60 * 1000)); + return `${hours}h ${minutes}m`; +} + +function printExpiry(expiresAt?: string): void { + if (!expiresAt) { + return; + } + const timeLeft = new Date(expiresAt).getTime() - Date.now(); + if (timeLeft <= 0) { + logger.info(`Expires in: ${chalk.red(formatTimeLeft(timeLeft))}`); + return; + } + logger.info(`Expires in: ${chalk.green(formatTimeLeft(timeLeft))}`); +} + +function printActiveOrganization(ctx: EffectiveContext, organizations: NonNullable): void { + const activeOrg = ctx.activeOrganization && organizations.find(o => o.machine_name === ctx.activeOrganization); + if (activeOrg) { + logger.info(`Active organization: ${chalk.green(activeOrg.name)} (${chalk.gray(activeOrg.roles.map((r: any) => r.display_name).join(', '))})`); + } +} + +function printContext(ctx: EffectiveContext): void { + const organizations = ctx.organizations ?? []; + if (organizations.length === 0) { + return; + } + logger.info(`Organizations: ${chalk.cyan(organizations.length.toString())}`); + printActiveOrganization(ctx, organizations); + if (ctx.activeApplication) { + logger.info(`Active application: ${chalk.cyan(ctx.activeApplication)}`); + } + if (ctx.activeEnvironment) { + logger.info(`Active environment: ${chalk.magenta(ctx.activeEnvironment)}`); + } + if (organizations.length > 1) { + logger.info(`${chalk.gray('Use')} ${chalk.cyan('quant-cloud org list')} ${chalk.gray('to see all organizations')}`); + } +} + async function handleWhoAmI() { try { // Get effective context (includes .quant.yml overrides) @@ -30,41 +83,8 @@ async function handleWhoAmI() { logger.info(`Host: ${chalk.cyan(effectiveContext.host)}`); logger.info(`Token: ${chalk.gray(effectiveContext.token.substring(0, 10) + '...')}`); - if (effectiveContext.expiresAt) { - const expiryDate = new Date(effectiveContext.expiresAt); - const now = new Date(); - const timeLeft = Math.max(0, expiryDate.getTime() - now.getTime()); - const hoursLeft = Math.floor(timeLeft / (1000 * 60 * 60)); - const minutesLeft = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); - - if (timeLeft > 0) { - logger.info(`Expires in: ${chalk.green(`${hoursLeft}h ${minutesLeft}m`)}`); - } - } - - if (effectiveContext.organizations && effectiveContext.organizations.length > 0) { - logger.info(`Organizations: ${chalk.cyan(effectiveContext.organizations.length.toString())}`); - - if (effectiveContext.activeOrganization) { - const activeOrg = effectiveContext.organizations.find(o => o.machine_name === effectiveContext.activeOrganization); - if (activeOrg) { - logger.info(`Active organization: ${chalk.green(activeOrg.name)} (${chalk.gray(activeOrg.roles.map((r: any) => r.display_name).join(', '))})`); - } - } - - if (effectiveContext.activeApplication) { - logger.info(`Active application: ${chalk.cyan(effectiveContext.activeApplication)}`); - } - - if (effectiveContext.activeEnvironment) { - logger.info(`Active environment: ${chalk.magenta(effectiveContext.activeEnvironment)}`); - } - - if (effectiveContext.organizations.length > 1) { - logger.info(`${chalk.gray('Use')} ${chalk.cyan('quant-cloud org list')} ${chalk.gray('to see all organizations')}`); - } - } - + printExpiry(effectiveContext.expiresAt); + printContext(effectiveContext); } catch (error: any) { if (error.message?.includes('Not authenticated')) { logger.info('Not authenticated. Run `quant-cloud login` to authenticate.'); diff --git a/src/utils/api.ts b/src/utils/api.ts index 2c3e561..fdc312d 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,5 +1,6 @@ -import { resolveEffectiveContext, ContextOverrides, EffectiveContext } from './context.js'; +import { resolveEffectiveContext, ContextOverrides, EffectiveContext, TokenRenewer } from './context.js'; import { Logger } from './logger.js'; +import { createRenewingAxios } from './auth-retry.js'; import { ApplicationsApi, EnvironmentsApi, SSHAccessApi, BackupManagementApi, ProjectsApi, CrawlersApi, CommandsApi, VariablesApi, SecretsApi, Configuration } from '@quantcdn/quant-client'; const logger = new Logger('API'); @@ -10,6 +11,19 @@ export interface ApiOptions { environmentId?: string; } +/** The HTTP status of an SDK error: axios sets response.status, older clients set statusCode. */ +function httpStatus(error: any): number | undefined { + return error?.statusCode ?? error?.response?.status; +} + +function environmentsNotFoundMessage(error: { body?: { message?: string } }, organizationId: string, applicationId: string): string { + const message: string = error.body?.message ?? ''; + if (message.includes('Application') && message.includes('not found')) { + return `Application '${organizationId}/${applicationId}' not found.`; + } + return `Organization '${organizationId}' not found or no access to environments.`; +} + export class ApiClient { private applicationsApi: ApplicationsApi; public environmentsApi: EnvironmentsApi; @@ -25,27 +39,35 @@ export class ApiClient { private defaultApplicationId?: string; private defaultEnvironmentId?: string; private token: string; + private renewToken?: TokenRenewer; + /** Set when a renewed token was refused too. Then this client stops renewing. */ + private renewalRejected = false; + + constructor(baseUrl: string, token: string, defaultOrganizationId?: string, defaultApplicationId?: string, defaultEnvironmentId?: string, renewToken?: TokenRenewer) { + this.token = token; + this.renewToken = renewToken; - constructor(baseUrl: string, token: string, defaultOrganizationId?: string, defaultApplicationId?: string, defaultEnvironmentId?: string) { - // Configure the APIs using Configuration object (v4.3.0) - // The client automatically handles Authorization header via accessToken + // The client reads accessToken for every request, so a renewed token is used at once. const config = new Configuration({ basePath: baseUrl, - accessToken: token + accessToken: () => this.token }); + // Legacy logins (no refresh token) keep the default axios and never retry. + const http = renewToken + ? createRenewingAxios((sent) => this.renew(sent), () => { this.renewalRejected = true; }) + : undefined; - this.applicationsApi = new ApplicationsApi(config); - this.environmentsApi = new EnvironmentsApi(config); - this.sshAccessApi = new SSHAccessApi(config); - this.backupManagementApi = new BackupManagementApi(config); - this.projectsApi = new ProjectsApi(config); - this.crawlersApi = new CrawlersApi(config); - this.commandsApi = new CommandsApi(config); - this.variablesApi = new VariablesApi(config); - this.secretsApi = new SecretsApi(config); + this.applicationsApi = new ApplicationsApi(config, undefined, http); + this.environmentsApi = new EnvironmentsApi(config, undefined, http); + this.sshAccessApi = new SSHAccessApi(config, undefined, http); + this.backupManagementApi = new BackupManagementApi(config, undefined, http); + this.projectsApi = new ProjectsApi(config, undefined, http); + this.crawlersApi = new CrawlersApi(config, undefined, http); + this.commandsApi = new CommandsApi(config, undefined, http); + this.variablesApi = new VariablesApi(config, undefined, http); + this.secretsApi = new SecretsApi(config, undefined, http); this.baseUrl = baseUrl; - this.token = token; this.defaultOrganizationId = defaultOrganizationId; this.defaultApplicationId = defaultApplicationId; this.defaultEnvironmentId = defaultEnvironmentId; @@ -59,10 +81,23 @@ export class ApiClient { context.token, context.activeOrganization, context.activeApplication, - context.activeEnvironment + context.activeEnvironment, + context.renewToken ); } + /** Renews the token after a 401 for sentToken. Returns the new token, or null. */ + private async renew(sentToken: string): Promise { + if (!this.renewToken || this.renewalRejected) { + return null; + } + const token = await this.renewToken(sentToken); + if (token) { + this.token = token; + } + return token; + } + // Old request method removed - now using TypeScript client // Using TypeScript client methods directly instead of manual HTTP calls @@ -80,12 +115,13 @@ export class ApiClient { const response = await this.applicationsApi.listApplications(organizationId); return response.data; } catch (error: any) { + const status = httpStatus(error); // Provide friendly error messages instead of debug dumps - if (error.statusCode === 404) { + if (status === 404) { throw new Error(`Organization '${organizationId}' not found or no access to applications.`); - } else if (error.statusCode === 403) { + } else if (status === 403) { throw new Error(`Access denied to applications in organization '${organizationId}'.`); - } else if (error.statusCode === 401) { + } else if (status === 401) { throw new Error('Authentication expired. Please run `qc login` to re-authenticate.'); } else { throw new Error(`Failed to fetch applications: ${error.message || 'Network error'}`); @@ -109,16 +145,13 @@ export class ApiClient { const response = await this.environmentsApi.listEnvironments(organizationId, applicationId); return response.data; } catch (error: any) { + const status = httpStatus(error); // Provide friendly error messages instead of debug dumps - if (error.statusCode === 404) { - if (error.body?.message?.includes('Application') && error.body?.message?.includes('not found')) { - throw new Error(`Application '${organizationId}/${applicationId}' not found.`); - } else { - throw new Error(`Organization '${organizationId}' not found or no access to environments.`); - } - } else if (error.statusCode === 403) { + if (status === 404) { + throw new Error(environmentsNotFoundMessage(error, organizationId, applicationId)); + } else if (status === 403) { throw new Error(`Access denied to environments in '${organizationId}/${applicationId}'.`); - } else if (error.statusCode === 401) { + } else if (status === 401) { throw new Error('Authentication expired. Please run `qc login` to re-authenticate.'); } else { throw new Error(`Failed to fetch environments: ${error.message || 'Network error'}`); @@ -131,16 +164,36 @@ export class ApiClient { throw new Error('getOrganizations not yet implemented - use getUserInfo instead'); } + private fetchUserInfo(token: string): Promise { + return fetch(`${this.baseUrl}/api/oauth/user`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + } + + /** fetchUserInfo, renewed and sent again once after a 401. */ + private async fetchUserInfoWithRenewal(): Promise { + const sent = this.token; + const response = await this.fetchUserInfo(sent); + if (response.status !== 401) { + return response; + } + const token = await this.renew(sent); + if (!token) { + return response; + } + const retried = await this.fetchUserInfo(token); + this.renewalRejected = retried.status === 401; + return retried; + } + async getUserInfo(): Promise { // OAuth user info is fetched directly, not through the TypeScript client // This endpoint is not part of the v3 API, it's part of the OAuth system try { - const response = await fetch(`${this.baseUrl}/api/oauth/user`, { - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Accept': 'application/json' - } - }); + const response = await this.fetchUserInfoWithRenewal(); if (!response.ok) { if (response.status === 401) { @@ -179,12 +232,13 @@ export class ApiClient { ); return response.data; } catch (error: any) { + const status = httpStatus(error); // Provide friendly error messages instead of debug dumps - if (error.statusCode === 404) { + if (status === 404) { throw new Error(`Environment '${organizationId}/${applicationId}/${options.environmentId}' not found or metrics unavailable.`); - } else if (error.statusCode === 403) { + } else if (status === 403) { throw new Error(`Access denied to metrics for '${organizationId}/${applicationId}/${options.environmentId}'.`); - } else if (error.statusCode === 401) { + } else if (status === 401) { throw new Error('Authentication expired. Please run `qc login` to re-authenticate.'); } else { throw new Error(`Failed to fetch metrics: ${error.message || 'Network error'}`); diff --git a/src/utils/auth-retry.ts b/src/utils/auth-retry.ts new file mode 100644 index 0000000..0f417bb --- /dev/null +++ b/src/utils/auth-retry.ts @@ -0,0 +1,39 @@ +import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +/** Called with the token a request sent. Returns a new token, or null. */ +export type Renew = (sentToken: string) => Promise; + +type RetryConfig = InternalAxiosRequestConfig & { _qcRenewed?: boolean }; + +function sentToken(config: RetryConfig): string { + return String(config.headers?.Authorization ?? '').replace(/^Bearer\s+/i, ''); +} + +async function retryAfterRenewal(http: AxiosInstance, renew: Renew, onRejected: () => void, error: AxiosError): Promise { + const config = error?.config as RetryConfig | undefined; + if (error?.response?.status !== 401 || !config) { + throw error; + } + if (config._qcRenewed) { + onRejected(); + throw error; + } + config._qcRenewed = true; + const token = await renew(sentToken(config)); + if (!token) { + throw error; + } + config.headers.set('Authorization', `Bearer ${token}`); + return http.request(config); +} + +/** + * An axios instance for the quant-client APIs. After a 401 it renews the token + * once and sends the same request again. A second 401 goes to the caller, and + * onRejected is called: the renewed token was refused too. + */ +export function createRenewingAxios(renew: Renew, onRejected: () => void): AxiosInstance { + const http = axios.create(); + http.interceptors.response.use(undefined, (error) => retryAfterRenewal(http, renew, onRejected, error)); + return http; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index e6cf3be..2ed1cd9 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,10 +1,14 @@ import { promises as fs } from 'fs'; import { homedir } from 'os'; -import { join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; +import { randomBytes } from 'crypto'; import { AuthConfig, MultiPlatformConfig, PlatformInfo } from '../types/auth.js'; +import { withFileLock } from './file-lock.js'; const CONFIG_DIR = process.env.QUANT_CONFIG_DIR || join(homedir(), '.quant'); export const CONFIG_FILE = join(CONFIG_DIR, 'credentials'); +/** Every qc process holds this lock while it reads and then rewrites CONFIG_FILE. */ +export const CREDENTIALS_LOCK_FILE = `${CONFIG_FILE}.lock`; export const VRT_CONFIG_FILE = join(CONFIG_DIR, 'vrt-config.json'); export interface VRTProjectConfig { @@ -34,9 +38,93 @@ export async function ensureConfigDir(): Promise { } } -export async function saveAuthConfig(config: AuthConfig): Promise { +/** Rename errors that a retry or an in-place write can get past. */ +const RENAME_FALLBACK_CODES = new Set(['EBUSY', 'EXDEV', 'EPERM', 'EACCES']); +const RENAME_BACKOFF_MS = [50, 150, 400]; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException)?.code; +} + +/** The real file behind CONFIG_FILE, so a symlinked file stays a symlink. */ +async function credentialsTarget(): Promise { + try { + return await fs.realpath(CONFIG_FILE); + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return CONFIG_FILE; + } + throw error; + } +} + +/** + * Renames temp over target. Retries EBUSY, EXDEV, EPERM and EACCES with a short + * backoff (antivirus, open handles on Windows). Returns false when the rename + * still fails with one of those codes; throws any other error. + */ +async function renameWithRetry(temp: string, target: string): Promise { + for (const delay of [...RENAME_BACKOFF_MS, -1]) { + try { + await fs.rename(temp, target); + return true; + } catch (error) { + if (!RENAME_FALLBACK_CODES.has(errorCode(error) ?? '')) { + throw error; + } + if (delay >= 0) { + await sleep(delay); + } + } + } + return false; +} + +/** Writes temp in the target's directory and renames it. Returns false if the rename cannot replace the target. */ +async function replaceByRename(target: string, content: string): Promise { + const temp = join(dirname(target), `.credentials.${process.pid}.${randomBytes(6).toString('hex')}.tmp`); + try { + await fs.writeFile(temp, content, { encoding: 'utf-8', mode: 0o600 }); + if (await renameWithRetry(temp, target)) { + return true; + } + } catch (error) { + await fs.unlink(temp).catch(() => undefined); + if (RENAME_FALLBACK_CODES.has(errorCode(error) ?? '')) { + return false; + } + throw error; + } + await fs.unlink(temp).catch(() => undefined); + return false; +} + +/** + * Replaces the credentials file, readable by its owner only. Normally this is a + * rename, so a reader sees the old file or the new file. When the rename cannot + * replace the file (a single-file bind mount gives EBUSY), the file is written + * in place; callers hold the credentials lock, so no qc process reads it then. + */ +async function writeCredentialsFile(content: string): Promise { + await ensureConfigDir(); + const target = await credentialsTarget(); + if (await replaceByRename(target, content)) { + return; + } + await fs.writeFile(target, content, { encoding: 'utf-8', mode: 0o600 }); + await fs.chmod(target, 0o600).catch(() => undefined); +} + +/** Runs a read-modify-write of the credentials file under the credentials lock. */ +export async function withCredentialsLock(fn: () => Promise): Promise { await ensureConfigDir(); - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); + return withFileLock(CREDENTIALS_LOCK_FILE, fn); +} + +export async function saveAuthConfig(config: AuthConfig): Promise { + await writeCredentialsFile(JSON.stringify(config, null, 2)); } export async function loadAuthConfig(): Promise { @@ -48,36 +136,71 @@ export async function loadAuthConfig(): Promise { } } +/** Deletes the credentials file under the credentials lock (qc logout). */ export async function clearAuthConfig(): Promise { + await withCredentialsLock(async () => { + try { + await fs.unlink(CONFIG_FILE); + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + throw error; + } + } + }); +} + +/** The parsed credentials file, or null when it does not exist. Other errors are thrown. */ +async function readCredentialsFile(): Promise { try { - await fs.unlink(CONFIG_FILE); - } catch { - // File doesn't exist, which is fine + return JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8')); + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return null; + } + throw error; + } +} + +function isMultiPlatform(parsed: MultiPlatformConfig | AuthConfig): parsed is MultiPlatformConfig { + return Boolean((parsed as MultiPlatformConfig).platforms); +} + +/** + * Reads the credentials for a caller that holds the credentials lock and saves + * afterwards. A file that cannot be read or parsed throws: an empty result here + * would make the next save drop every platform. An old single-platform file is + * migrated and saved. + */ +export async function loadCredentialsLocked(): Promise { + const parsed = await readCredentialsFile(); + if (!parsed) { + return { platforms: {} }; + } + if (isMultiPlatform(parsed)) { + return parsed; } + return migrateSinglePlatformConfig(parsed); } -// Multi-platform configuration management +// Multi-platform configuration management. Read-only callers get an empty +// config when the file is missing or cannot be read. export async function loadMultiPlatformConfig(): Promise { try { - const data = await fs.readFile(CONFIG_FILE, 'utf-8'); - const parsed = JSON.parse(data); - - // Check if it's already a multi-platform config - if (parsed.platforms) { - return parsed as MultiPlatformConfig; + const parsed = await readCredentialsFile(); + if (!parsed) { + return { platforms: {} }; + } + if (isMultiPlatform(parsed)) { + return parsed; } - - // Migrate single-platform config to multi-platform - return await migrateSinglePlatformConfig(parsed as AuthConfig); + return await withCredentialsLock(loadCredentialsLocked); } catch { - // Return empty multi-platform config return { platforms: {} }; } } export async function saveMultiPlatformConfig(config: MultiPlatformConfig): Promise { - await ensureConfigDir(); - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); + await writeCredentialsFile(JSON.stringify(config, null, 2)); } export async function getActivePlatformConfig(): Promise { @@ -98,7 +221,11 @@ export async function getActivePlatformConfig(): Promise { } export async function savePlatformConfig(platformId: string, authConfig: AuthConfig, platformInfo: PlatformInfo): Promise { - const multiConfig = await loadMultiPlatformConfig(); + await withCredentialsLock(() => applyPlatformConfig(platformId, authConfig, platformInfo)); +} + +async function applyPlatformConfig(platformId: string, authConfig: AuthConfig, platformInfo: PlatformInfo): Promise { + const multiConfig = await loadCredentialsLocked(); multiConfig.platforms[platformId] = { ...authConfig, @@ -114,7 +241,11 @@ export async function savePlatformConfig(platformId: string, authConfig: AuthCon } export async function switchPlatform(platformId: string): Promise { - const multiConfig = await loadMultiPlatformConfig(); + return withCredentialsLock(() => applySwitchPlatform(platformId)); +} + +async function applySwitchPlatform(platformId: string): Promise { + const multiConfig = await loadCredentialsLocked(); if (!multiConfig.platforms[platformId]) { return false; @@ -136,7 +267,11 @@ export async function listPlatforms(): Promise { - const multiConfig = await loadMultiPlatformConfig(); + return withCredentialsLock(() => applyRemovePlatform(platformId)); +} + +async function applyRemovePlatform(platformId: string): Promise { + const multiConfig = await loadCredentialsLocked(); if (!multiConfig.platforms[platformId]) { return false; @@ -154,7 +289,7 @@ export async function removePlatform(platformId: string): Promise { return true; } -// Migration helper +// Migration helper. The caller holds the credentials lock. async function migrateSinglePlatformConfig(authConfig: AuthConfig): Promise { const platformInfo: PlatformInfo = generatePlatformInfo(authConfig.host); @@ -203,7 +338,12 @@ function generatePlatformInfo(host: string): PlatformInfo { // Helper to save updates to a named platform export async function savePlatformUpdates(platformId: string, updates: Partial): Promise { - const multiConfig = await loadMultiPlatformConfig(); + await withCredentialsLock(() => applyPlatformUpdates(platformId, updates)); +} + +/** savePlatformUpdates for a caller that already holds the credentials lock. */ +export async function applyPlatformUpdates(platformId: string, updates: Partial): Promise { + const multiConfig = await loadCredentialsLocked(); const current = multiConfig.platforms[platformId]; if (!current) { throw new Error(`Platform configuration not found: ${platformId}`); diff --git a/src/utils/context.ts b/src/utils/context.ts index c07faa0..3c2e8ba 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -1,7 +1,8 @@ -import { getActivePlatformConfig, loadMultiPlatformConfig, saveActivePlatformConfig, savePlatformUpdates } from './config.js'; +import { applyPlatformUpdates, CONFIG_FILE, CREDENTIALS_LOCK_FILE, loadCredentialsLocked, loadMultiPlatformConfig, saveActivePlatformConfig, savePlatformUpdates } from './config.js'; import type { AuthConfig } from '../types/auth.js'; import { getProjectConfig, ProjectConfig } from './project-config.js'; import { Logger } from './logger.js'; +import { forceRefresh, isRefreshDue, refreshIfDue, RefreshStore } from './token-refresh.js'; const logger = new Logger('Context'); @@ -21,21 +22,86 @@ export interface EffectiveContext { activeApplication?: string; activeEnvironment?: string; organizations?: any[]; + /** Present only for logins with a refresh token. See tokenRenewer(). */ + renewToken?: TokenRenewer; +} + +/** + * Called after the portal answers 401 for sentToken. Returns a new access token, + * or null when the login cannot be renewed. + */ +export type TokenRenewer = (sentToken: string) => Promise; + +/** The stored login for a platform, or null when there is none. */ +async function loadStoredAuth(platformId: string, load = loadMultiPlatformConfig): Promise { + const multiConfig = await load(); + const stored = multiConfig.platforms[platformId]; + if (!stored) { + return null; + } + const { platformInfo, ...auth } = stored; + return auth; } /** * Loads the stored credentials for a named platform without changing the persisted active platform. */ async function loadPlatformAuth(platformId: string): Promise { - const multiConfig = await loadMultiPlatformConfig(); - const stored = multiConfig.platforms[platformId]; - if (!stored || !stored.token) { + const auth = await loadStoredAuth(platformId); + if (!auth || !auth.token) { throw new Error(`Not logged in to platform ${platformId}. Run qc login and choose ${platformId}, or qc platform list.`); } - const { platformInfo, ...auth } = stored; return auth; } +/** + * The refresh store for one platform. It is bound to the platform id, so a + * refresh never writes to another platform, even if the active one changes. + */ +export function platformRefreshStore(platformId: string): RefreshStore { + return { + // Called under the lock: a file that cannot be read throws instead of looking empty. + reload: () => loadStoredAuth(platformId, loadCredentialsLocked), + save: (updates) => applyPlatformUpdates(platformId, updates), + lockFile: CREDENTIALS_LOCK_FILE, + location: CONFIG_FILE, + }; +} + +/** The proactive refresh of each platform in this process, keyed by platform id. */ +const refreshes = new Map }>(); + +/** + * refreshIfDue, at most once for each platform in this process. A second call + * for the same stored token (for example when the save failed) gets the first + * result, so this process never sends the same refresh token twice. + */ +async function refreshOncePerProcess(platformId: string, auth: AuthConfig): Promise { + const earlier = refreshes.get(platformId); + if (earlier && earlier.from === auth.token) { + const { token, refreshToken, expiresAt } = await earlier.result; + return { ...auth, token, refreshToken, expiresAt }; + } + if (!isRefreshDue(auth)) { + return auth; + } + const result = refreshIfDue(auth, platformRefreshStore(platformId)); + refreshes.set(platformId, { from: auth.token, result }); + return result; +} + +/** A TokenRenewer for this login, or undefined for a legacy login without a refresh token. */ +export function tokenRenewer(auth: AuthConfig, platformId: string): TokenRenewer | undefined { + if (!auth.refreshToken) { + return undefined; + } + const store = platformRefreshStore(platformId); + return async (sentToken) => { + const renewed = await forceRefresh({ ...auth, token: sentToken }, store); + return renewed?.token ?? null; + }; +} + function describe(values: Record): string { return Object.entries(values) .filter(([, v]) => v) @@ -68,8 +134,22 @@ export function requestedPlatform(flag?: string): string | undefined { * token and stored org/app context always come from the same platform. */ export async function getEffectiveAuth(platformFlag?: string): Promise { - const id = requestedPlatform(platformFlag); - return id ? loadPlatformAuth(id) : getActivePlatformConfig(); + const effective = await loadEffectiveAuth(platformFlag); + return effective ? effective.auth : null; +} + +/** getEffectiveAuth, plus the id of the platform the login came from. */ +async function loadEffectiveAuth(platformFlag?: string): Promise<{ platformId: string; auth: AuthConfig } | null> { + const requested = requestedPlatform(platformFlag); + const platformId = requested || (await loadMultiPlatformConfig()).activePlatform; + if (!platformId) { + return null; + } + const auth = requested ? await loadPlatformAuth(platformId) : await loadStoredAuth(platformId); + if (!auth) { + return null; + } + return { platformId, auth: await refreshOncePerProcess(platformId, auth) }; } /** @@ -93,10 +173,11 @@ export async function resolveEffectiveContext(overrides: ContextOverrides = {}): const projectConfig = getProjectConfig(); // Platform priority: CLI flag > .quant.yml > active platform. Never persisted. - const auth = await getEffectiveAuth(overrides.platform); - if (!auth || !auth.token) { + const effective = await loadEffectiveAuth(overrides.platform); + if (!effective || !effective.auth.token) { throw new Error('Not authenticated. Run `quant-cloud login` to authenticate.'); } + const { auth, platformId } = effective; // Apply in priority order: CLI flags > project config > stored config const effectiveContext: EffectiveContext = { @@ -108,6 +189,7 @@ export async function resolveEffectiveContext(overrides: ContextOverrides = {}): activeOrganization: overrides.org || projectConfig.org || auth.activeOrganization, activeApplication: overrides.app || projectConfig.app || auth.activeApplication, activeEnvironment: overrides.env || projectConfig.env || auth.activeEnvironment, + renewToken: tokenRenewer(auth, platformId), }; logContextSources(overrides, projectConfig, effectiveContext); diff --git a/src/utils/file-lock.ts b/src/utils/file-lock.ts new file mode 100644 index 0000000..82f5e48 --- /dev/null +++ b/src/utils/file-lock.ts @@ -0,0 +1,249 @@ +import { promises as fs, readFileSync, unlinkSync } from 'fs'; +import { randomBytes } from 'crypto'; + +/** + * A lock older than this (by the holder's own timestamp) belongs to a process + * that died, and is taken over. It must be longer than the refresh timeout. + */ +export const LOCK_STALE_MS = 90_000; + +const POLL_MS = 100; +/** + * A refresh with recovery retries can hold the lock for about 5 minutes (the + * last retry starts within 4 minutes and has a 60 s timeout), so a waiter waits + * longer than that. A dead holder is still taken over after LOCK_STALE_MS. + */ +const MAX_WAIT_MS = 6 * 60_000; +const DEFERRED_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + +/** What a holder writes into the lock file. `time` is the holder's own clock. */ +interface LockRecord { + nonce: string; + pid: number; + time: number; +} + +/** Passed to the locked function. */ +export interface HeldLock { + /** Throws when another process has taken the lock over. */ + verify(): Promise; + /** + * Verifies ownership and writes a new timestamp, so a holder that must keep + * the lock longer than LOCK_STALE_MS is not taken over. + */ + touch(): Promise; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException)?.code; +} + +function parseRecord(content: string): LockRecord | null { + try { + const record = JSON.parse(content) as LockRecord; + return typeof record.nonce === 'string' && typeof record.time === 'number' ? record : null; + } catch { + return null; + } +} + +/** The lock file content, or null when there is no lock file. */ +async function readLock(lockFile: string): Promise { + try { + return await fs.readFile(lockFile, 'utf-8'); + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return null; + } + throw error; + } +} + +/** + * Staleness comes from the holder's timestamp in the file, not from mtime. A file + * without a valid record (a holder between create and write) uses its mtime. + */ +async function isStale(lockFile: string, content: string): Promise { + const record = parseRecord(content); + if (record) { + return Date.now() - record.time > LOCK_STALE_MS; + } + const { mtimeMs } = await fs.stat(lockFile); + return Date.now() - mtimeMs > LOCK_STALE_MS; +} + +/** Puts a lock file that was moved aside back, unless a new lock exists now. */ +async function restoreLock(aside: string, lockFile: string): Promise { + try { + await fs.link(aside, lockFile); + await fs.unlink(aside); + } catch (error) { + if (errorCode(error) === 'EEXIST') { + await fs.unlink(aside).catch(() => undefined); + return; + } + await fs.rename(aside, lockFile).catch(() => undefined); + } +} + +/** + * Removes a stale lock. The file is first moved to a unique name, so only one + * process can take it. If the moved file is not the stale lock that was + * inspected (another process replaced it), it is put back. + */ +async function breakStaleLock(lockFile: string, inspected: string): Promise { + const aside = `${lockFile}.stale.${randomBytes(6).toString('hex')}`; + try { + await fs.rename(lockFile, aside); + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return; + } + throw error; + } + if ((await readLock(aside)) === inspected) { + await fs.unlink(aside).catch(() => undefined); + return; + } + await restoreLock(aside, lockFile); +} + +async function removeIfStale(lockFile: string): Promise { + const content = await readLock(lockFile); + if (content !== null && (await isStale(lockFile, content))) { + await breakStaleLock(lockFile, content); + } +} + +/** Writes the record. On failure the half-made lock file is removed. */ +async function writeRecord(handle: fs.FileHandle, lockFile: string, record: LockRecord): Promise { + try { + await handle.writeFile(JSON.stringify(record)); + } catch (error) { + await handle.close().catch(() => undefined); + await fs.unlink(lockFile).catch(() => undefined); + throw error; + } + await handle.close(); +} + +/** Creates the lock file. Returns the record, or null when another process holds it. */ +async function tryAcquire(lockFile: string): Promise { + let handle: fs.FileHandle; + try { + handle = await fs.open(lockFile, 'wx', 0o600); + } catch (error) { + if (errorCode(error) === 'EEXIST') { + return null; + } + throw error; + } + const record = { nonce: randomBytes(16).toString('hex'), pid: process.pid, time: Date.now() }; + await writeRecord(handle, lockFile, record); + return record; +} + +async function acquire(lockFile: string): Promise { + const deadline = Date.now() + MAX_WAIT_MS; + for (;;) { + const record = await tryAcquire(lockFile); + if (record) { + return record; + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${lockFile}. If no other qc command is running, delete that file.`); + } + await removeIfStale(lockFile); + await sleep(POLL_MS); + } +} + +function ownsSync(lockFile: string, nonce: string): boolean { + try { + return parseRecord(readFileSync(lockFile, 'utf-8'))?.nonce === nonce; + } catch { + return false; + } +} + +async function owns(lockFile: string, nonce: string): Promise { + const content = await readLock(lockFile).catch(() => null); + return content !== null && parseRecord(content)?.nonce === nonce; +} + +/** Releases the lock only if this holder still owns it. */ +async function release(lockFile: string, nonce: string): Promise { + if (await owns(lockFile, nonce)) { + await fs.unlink(lockFile).catch(() => undefined); + } +} + +/** + * Removes the lock on process.exit, and defers SIGINT/SIGTERM until the locked + * work is done, so a refresh is not cut off between the request and the save. + */ +function guardProcess(lockFile: string, nonce: string): () => void { + const pending: NodeJS.Signals[] = []; + const onExit = () => { + if (ownsSync(lockFile, nonce)) { + try { + unlinkSync(lockFile); + } catch { + // Nothing more can be done while the process exits. + } + } + }; + const removeListeners = () => { + process.off('exit', onExit); + DEFERRED_SIGNALS.forEach((signal) => process.off(signal, onSignal)); + }; + // The first signal waits for the locked work. A second one stops at once. + // A process killed by a signal emits no 'exit', so the lock is removed first. + function onSignal(signal: NodeJS.Signals) { + if (pending.length > 0) { + removeListeners(); + onExit(); + process.kill(process.pid, signal); + return; + } + pending.push(signal); + process.stderr.write('Finishing the Quant login update. Press Ctrl-C again to stop now.\n'); + } + process.on('exit', onExit); + DEFERRED_SIGNALS.forEach((signal) => process.on(signal, onSignal)); + return () => { + removeListeners(); + if (pending.length > 0) { + process.kill(process.pid, pending[0]); + } + }; +} + +/** + * Runs fn while this process holds an exclusive lock file. Every qc process that + * uses the same lock file waits for the others, in this process and in others. + */ +export async function withFileLock(lockFile: string, fn: (lock: HeldLock) => Promise): Promise { + const { nonce } = await acquire(lockFile); + const unguard = guardProcess(lockFile, nonce); + const verify = async () => { + if (!(await owns(lockFile, nonce))) { + throw new Error(`Lost the lock ${lockFile} to another process`); + } + }; + const lock: HeldLock = { + verify, + touch: async () => { + await verify(); + await fs.writeFile(lockFile, JSON.stringify({ nonce, pid: process.pid, time: Date.now() })); + }, + }; + try { + return await fn(lock); + } finally { + await release(lockFile, nonce); + unguard(); + } +} diff --git a/src/utils/token-refresh.ts b/src/utils/token-refresh.ts new file mode 100644 index 0000000..7d99944 --- /dev/null +++ b/src/utils/token-refresh.ts @@ -0,0 +1,325 @@ +import type { AuthConfig, LoginResponse } from '../types/auth.js'; +import { HeldLock, withFileLock } from './file-lock.js'; +import { Logger } from './logger.js'; + +const logger = new Logger('Auth'); + +const CLIENT_ID = 'quant-cli'; + +/** Refresh when the access token has this much life or less left. */ +export const REFRESH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * The portal can stall for a long time, and it commits a rotation even when the + * client has gone, so qc waits long for the answer. The timeout must stay below + * LOCK_STALE_MS, or a waiting process takes the lock and sends the same refresh + * token, which revokes the chain. + */ +export const REFRESH_TIMEOUT_MS = 60_000; + +/** + * After an unknown outcome, qc sends the same refresh token again. The portal + * answers the previous token with "just rotated" for 30 s after a rotation, and + * recovers a lost rotation (a new pair, once) from 30 s to 5 minutes. So qc + * waits delayMs after the previous attempt ended, sends at most maxRetries + * retries, and sends none when windowMs has passed since the first send. + * Tests may lower these values. + */ +export const recovery = { delayMs: 45_000, maxRetries: 2, windowMs: 4 * 60_000 }; + +/** After this long, qc tells the user that it is still renewing the login. */ +const SLOW_NOTICE_MS = 5_000; + +/** Refresh tokens the portal refused in this process. They are not sent again. */ +const rejectedTokens = new Set(); + +/** Where refreshed credentials go, and how to read what another qc process saved. */ +export interface RefreshStore { + /** Saves the refreshed tokens. Called while the lock is held. */ + save(updates: Partial): Promise; + /** Reads the stored login again. */ + reload(): Promise; + /** The lock file that every qc process holds while it refreshes or saves. */ + lockFile: string; + /** The credentials file, for messages. */ + location: string; +} + +/** + * The portal may have rotated the tokens, but qc did not get the new pair: a + * timeout, a network error, a 5xx, or a 200 body that cannot be read. + */ +export class UnknownRefreshOutcome extends Error { + constructor(reason: string) { + super(reason); + this.name = 'UnknownRefreshOutcome'; + } +} + +/** The portal refused the refresh token (HTTP 400 invalid_grant or invalid_client). */ +export class TokenRefreshError extends Error { + constructor(public readonly code: string, description?: string) { + super(description || code); + this.name = 'TokenRefreshError'; + } +} + +/** + * True when the stored login can and should be refreshed. Legacy logins have no + * refresh token or expiry and are never refreshed. + */ +export function isRefreshDue(auth: AuthConfig, now: number = Date.now()): boolean { + if (!auth.refreshToken || !auth.expiresAt) { + return false; + } + const expiry = Date.parse(auth.expiresAt); + return Number.isNaN(expiry) || expiry - now <= REFRESH_WINDOW_MS; +} + +/** + * The credential fields to store from an /oauth/token response. A response + * without refresh_token keeps the stored refresh token: the key is left out. + */ +export function toAuthUpdates(body: LoginResponse, now: number = Date.now()): Partial { + const updates: Partial = { + token: body.access_token, + expiresAt: typeof body.expires_in === 'number' + ? new Date(now + body.expires_in * 1000).toISOString() + : undefined, + }; + if (body.refresh_token) { + updates.refreshToken = body.refresh_token; + } + return updates; +} + +interface TokenErrorBody { + error?: string; + error_description?: string; +} + +/** "Just rotated": a request with this token rotated less than 30 s ago, maybe our own. */ +function isJustRotated(body: TokenErrorBody): boolean { + return body.error === 'invalid_grant' && /just rotated/i.test(body.error_description ?? ''); +} + +function refreshFailure(status: number, body: TokenErrorBody): Error { + if (status >= 500 || (status >= 200 && status < 300)) { + return new UnknownRefreshOutcome(`HTTP ${status} without a usable token response`); + } + if (isJustRotated(body)) { + return new UnknownRefreshOutcome(body.error_description as string); + } + if (body.error) { + return new TokenRefreshError(body.error, body.error_description); + } + return new Error(`HTTP ${status}`); +} + +/** Prints a note on stderr when the refresh takes longer than SLOW_NOTICE_MS. Returns a function that cancels it. */ +function noticeIfSlow(): () => void { + const timer = setTimeout(() => process.stderr.write('Renewing Quant login…\n'), SLOW_NOTICE_MS); + timer.unref?.(); + return () => clearTimeout(timer); +} + +async function requestRefresh(auth: AuthConfig): Promise> { + const cancelNotice = noticeIfSlow(); + try { + return await sendRefresh(auth); + } finally { + cancelNotice(); + } +} + +async function sendRefresh(auth: AuthConfig): Promise> { + const refreshToken = auth.refreshToken as string; + if (rejectedTokens.has(refreshToken)) { + throw new TokenRefreshError('invalid_grant', 'Refresh token was refused earlier'); + } + const response = await postRefresh(auth.host, refreshToken); + const body = await response.json().catch(() => ({})) as Partial & TokenErrorBody; + if (!response.ok || typeof body.access_token !== 'string') { + const failure = refreshFailure(response.status, body); + if (failure instanceof TokenRefreshError) { + rejectedTokens.add(refreshToken); + } + throw failure; + } + return toAuthUpdates(body as LoginResponse); +} + +/** Sends the refresh request. A timeout or network error is an unknown outcome. */ +async function postRefresh(host: string, refreshToken: string): Promise { + try { + return await sendRefreshRequest(host, refreshToken); + } catch (error) { + throw new UnknownRefreshOutcome(error instanceof Error ? error.message : String(error)); + } +} + +function sendRefreshRequest(host: string, refreshToken: string): Promise { + return fetch(new URL('/oauth/token', host).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: refreshToken, + }).toString(), + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), + }); +} + +/** + * Refreshes under the lock. The stored credentials are read again first: when + * another qc process already rotated the token, its result is used and no + * request is sent, so one refresh token is never sent twice. + */ +async function refreshLocked( + auth: AuthConfig, + store: RefreshStore, + shouldRefresh: (latest: AuthConfig) => boolean, +): Promise { + return withFileLock(store.lockFile, async (lock: HeldLock) => { + const latest = await store.reload(); + if (!latest || latest.token !== auth.token || !shouldRefresh(latest)) { + return latest ?? auth; + } + await lock.verify(); + const updates = await refreshWithRecovery(latest, store, lock); + await saveRotation(store, updates); + logger.debug('Access token refreshed'); + return { ...latest, ...updates }; + }); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function tokenFields(auth: AuthConfig): Partial { + return { token: auth.token, refreshToken: auth.refreshToken, expiresAt: auth.expiresAt }; +} + +/** + * Waits before a recovery retry, with the lock kept fresh. Returns the stored + * tokens when they changed during the wait (the retry is then not sent), or + * null to send the retry. Throws lastError when the window has passed. + */ +async function prepareRetry( + auth: AuthConfig, + store: RefreshStore, + lock: HeldLock, + firstSend: number, + lastError: unknown, +): Promise | null> { + await lock.touch(); + process.stderr.write(`Renewing Quant login: no clear answer from the server. Waiting ${Math.round(recovery.delayMs / 1000)} s…\n`); + await sleep(recovery.delayMs); + await lock.touch(); + if (Date.now() - firstSend > recovery.windowMs) { + throw lastError; + } + const latest = await store.reload(); + if (!latest) { + throw lastError; + } + return latest.token !== auth.token ? tokenFields(latest) : null; +} + +/** + * requestRefresh, sent again with the same refresh token after an unknown + * outcome (see `recovery`). A definite 4xx is never retried. Both waits and all + * attempts take longer than LOCK_STALE_MS, so the lock is touched around each wait. + */ +async function refreshWithRecovery(auth: AuthConfig, store: RefreshStore, lock: HeldLock): Promise> { + const firstSend = Date.now(); + let lastError: unknown; + for (let attempt = 0; attempt <= recovery.maxRetries; attempt++) { + const stored = attempt > 0 ? await prepareRetry(auth, store, lock, firstSend, lastError) : null; + if (stored) { + return stored; + } + try { + return await requestRefresh(auth); + } catch (error) { + if (!(error instanceof UnknownRefreshOutcome)) { + throw error; + } + lastError = error; + logger.debug(`Refresh outcome unknown (${error.message})`); + } + } + throw lastError; +} + +/** + * Saves a rotated pair, with one retry. The portal has already rotated, so the + * new pair is used for this command even when it cannot be saved. + */ +async function saveRotation(store: RefreshStore, updates: Partial): Promise { + try { + await store.save(updates); + } catch { + try { + await store.save(updates); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`Could not save the refreshed login to ${store.location} (${reason}): run \`qc login\` if later commands fail.`); + } + } +} + +let warnedRejected = false; + +function reportFailure(auth: AuthConfig, error: unknown): void { + const reason = error instanceof Error ? error.message : String(error); + if (error instanceof TokenRefreshError) { + if (warnedRejected) { + return; + } + warnedRejected = true; + logger.warn(`Your Quant login could not be renewed (${reason}). Run \`qc login\` to sign in again.`); + return; + } + logger.debug(`Token refresh failed: ${reason}`); + if (auth.expiresAt && Date.parse(auth.expiresAt) <= Date.now()) { + logger.warn(`Your Quant login expired and could not be renewed (${reason}). Run \`qc login\` to sign in again.`); + } +} + +/** + * Returns the login to use for this command. When the access token is close to + * expiry, it is exchanged for a new pair, which is saved. On failure the stored + * login is returned unchanged: it is still valid if it has not expired. + */ +export async function refreshIfDue(auth: AuthConfig, store: RefreshStore): Promise { + if (!isRefreshDue(auth)) { + return auth; + } + try { + return await refreshLocked(auth, store, (latest) => isRefreshDue(latest)); + } catch (error) { + reportFailure(auth, error); + return auth; + } +} + +/** + * Refreshes after the portal rejected auth.token with 401, even when the token + * is not yet due. Returns the new login, or null when no new token is available. + */ +export async function forceRefresh(auth: AuthConfig, store: RefreshStore): Promise { + if (!auth.refreshToken) { + return null; + } + try { + const renewed = await refreshLocked(auth, store, (latest) => Boolean(latest.refreshToken)); + return renewed.token !== auth.token ? renewed : null; + } catch (error) { + reportFailure(auth, error); + return null; + } +}