From 131273b5a5371f877cd64199724a3a3a0c97d687 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Wed, 8 Apr 2026 17:28:41 +0400 Subject: [PATCH 01/10] Add interactive login --- README.md | 49 ++- package-lock.json | 239 ++++++++++++++- package.json | 1 + src/api/deviceAuth.ts | 68 +++++ src/api/projects.ts | 6 + src/commands/auth.ts | 214 +++++++++++++ src/commands/main.ts | 5 +- src/commands/resultUpload.ts | 2 +- src/tests/auth-e2e.spec.ts | 560 +++++++++++++++++++++++++++++++++++ src/utils/browser.ts | 15 + src/utils/config.ts | 1 + src/utils/credentials.ts | 141 +++++++++ src/utils/env.ts | 109 ++++--- src/utils/prompt.ts | 67 +++++ 14 files changed, 1426 insertions(+), 51 deletions(-) create mode 100644 src/api/deviceAuth.ts create mode 100644 src/commands/auth.ts create mode 100644 src/tests/auth-e2e.spec.ts create mode 100644 src/utils/browser.ts create mode 100644 src/utils/credentials.ts create mode 100644 src/utils/prompt.ts diff --git a/README.md b/README.md index c8dbde7..4c018b9 100644 --- a/README.md +++ b/README.md @@ -34,29 +34,52 @@ Verify installation: `qasphere --version` **Update:** Run `npm update -g qas-cli` to get the latest version. -## Environment +## Authentication -The CLI requires the following variables to be defined: +The recommended way to authenticate is using the interactive login command: -- `QAS_TOKEN` - QA Sphere API token (see [docs](https://docs.qasphere.com/api/authentication) if you need help generating one) -- `QAS_URL` - Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) +```bash +qasphere auth login +``` + +This opens your browser to complete authentication and securely stores your credentials in the system keyring. If a keyring is not available, credentials are stored in `~/.config/qasphere/credentials.json` with restricted file permissions. + +You can also log in by directly providing an API key: + +```bash +qasphere auth login --api-key +``` + +### Other auth commands -These variables could be defined: +```bash +qasphere auth status # Show current authentication status +qasphere auth logout # Clear stored credentials +``` + +### Credential resolution order + +The CLI resolves credentials in the following order (first match wins): + +1. `QAS_TOKEN` and `QAS_URL` environment variables +2. `.env` file in the current working directory +3. System keyring (set by `qasphere auth login`) +4. `~/.config/qasphere/credentials.json` (fallback when keyring is unavailable) +5. `.qaspherecli` file in the current directory or any parent directory -- as environment variables -- in .env of a current working directory -- in a special `.qaspherecli` configuration file in your project directory (or any parent directory) +### Manual configuration -Example: .qaspherecli +Instead of using `auth login`, you can manually set the required variables: + +- `QAS_TOKEN` - QA Sphere API token (see [docs](https://docs.qasphere.com/api/authentication) if you need help generating one) +- `QAS_URL` - Base URL of your QA Sphere instance (e.g., `https://qas.eu2.qasphere.com`) + +These variables can be defined as environment variables, in a `.env` file, or in a `.qaspherecli` configuration file: ```sh # .qaspherecli QAS_TOKEN=your_token QAS_URL=https://qas.eu1.qasphere.com - -# Example with real values: -# QAS_TOKEN=qas.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest -# QAS_URL=https://qas.eu1.qasphere.com ``` ## Commands: `junit-upload`, `playwright-json-upload`, `allure-upload` diff --git a/package-lock.json b/package-lock.json index b98862f..a5b1856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "qas-cli", - "version": "0.4.6", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.4.6", + "version": "0.6.0", "license": "ISC", "dependencies": { + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "dotenv": "^16.5.0", "escape-html": "^1.0.3", @@ -755,6 +756,240 @@ "node": ">=18" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", + "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.2.0", + "@napi-rs/keyring-darwin-x64": "1.2.0", + "@napi-rs/keyring-freebsd-x64": "1.2.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", + "@napi-rs/keyring-linux-arm64-musl": "1.2.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-musl": "1.2.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", + "@napi-rs/keyring-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", + "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", + "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 4ca64d2..7f41f8d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "vitest": "^3.1.2" }, "dependencies": { + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "dotenv": "^16.5.0", "escape-html": "^1.0.3", diff --git a/src/api/deviceAuth.ts b/src/api/deviceAuth.ts new file mode 100644 index 0000000..03eb631 --- /dev/null +++ b/src/api/deviceAuth.ts @@ -0,0 +1,68 @@ +import { withBaseUrl, withJson, withHttpRetry, jsonResponse } from './utils' +import { CLI_VERSION } from '../utils/version' +import { LOGIN_SERVICE_URL } from '../utils/config' + +export interface CheckTenantResponse { + redirectUrl: string +} + +export interface DeviceCodeResponse { + userCode: string + deviceCode: string + expiresIn: number + interval: number +} + +export interface DeviceTokenPendingResponse { + status: 'pending' +} + +export interface DeviceTokenApprovedResponse { + status: 'approved' + data: { + apiKey: string + apiKeyName: string + tenantUrl: string + email: string + } +} + +type DeviceTokenResponse = DeviceTokenPendingResponse | DeviceTokenApprovedResponse + +const createFetcher = (baseUrl: string) => withHttpRetry(withJson(withBaseUrl(fetch, baseUrl))) + +export async function checkTenant(teamName: string): Promise { + const fetcher = createFetcher(LOGIN_SERVICE_URL) + const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { + method: 'GET', + headers: { 'User-Agent': `qas-cli/${CLI_VERSION}` }, + }) + const data = await jsonResponse(response) + + // The check-tenant endpoint returns a redirect URL (e.g. http://tenant.localhost:5173/login). + // Extract just the origin for use as the API base URL. + const origin = new URL(data.redirectUrl).origin + return { redirectUrl: origin } +} + +export async function requestDeviceCode(tenantUrl: string): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/auth/device/code', { + method: 'POST', + headers: { 'User-Agent': `qas-cli/${CLI_VERSION}` }, + }) + return jsonResponse(response) +} + +export async function pollDeviceToken( + tenantUrl: string, + deviceCode: string +): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/auth/device/token', { + method: 'POST', + headers: { 'User-Agent': `qas-cli/${CLI_VERSION}` }, + body: JSON.stringify({ deviceCode }), + }) + return jsonResponse(response) +} diff --git a/src/api/projects.ts b/src/api/projects.ts index 4236b7d..310a8f1 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -1,6 +1,12 @@ +import { jsonResponse } from './utils' + export const createProjectApi = (fetcher: typeof fetch) => ({ checkProjectExists: async (project: string) => { const res = await fetcher(`/api/public/v0/project/${project}`) return res.ok }, + listProjects: async () => { + const res = await fetcher('/api/public/v0/project') + return jsonResponse(res) + }, }) diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..3b44ee1 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,214 @@ +import { Argv, CommandModule } from 'yargs' +import chalk from 'chalk' +import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' +import { openBrowser } from '../utils/browser' +import { twirlLoader } from '../utils/misc' +import { saveCredentials, clearCredentials, CredentialSource } from '../utils/credentials' +import { createApi } from '../api' +import { + checkTenant, + requestDeviceCode, + pollDeviceToken, + DeviceCodeResponse, +} from '../api/deviceAuth' +import { resolveCredentialSource } from '../utils/env' + +interface AuthLoginArgs { + 'api-key'?: boolean +} + +async function resolveTenantUrl(): Promise { + const teamName = await prompt('Team name: ') + if (!teamName) { + console.error(chalk.red('Error:') + ' Team name is required.') + process.exit(1) + } + + try { + const result = await checkTenant(teamName) + return result.redirectUrl + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Could not find team "${teamName}": ${message}`) + process.exit(1) + } +} + +async function handleApiKeyLogin(): Promise { + const tenantUrl = await resolveTenantUrl() + const apiKey = await promptHidden('API Key: ') + if (!apiKey) { + console.error(chalk.red('Error:') + ' API key is required.') + process.exit(1) + } + + const source = await saveCredentials({ apiKey: apiKey, tenantUrl: tenantUrl }) + console.log(chalk.green('✓') + ` Logged in to ${tenantUrl}`) + console.log(` Credentials saved to ${source}.`) +} + +/** + * Device Authorization Grant flow (RFC 8628). + * + * 1. CLI requests a device code and user code from the tenant backend. + * 2. CLI displays the user code and opens the browser to the verification URL. + * 3. The user enters the code in the browser and approves the device. + * 4. CLI polls the tenant backend until the user approves or the code expires. + * 5. On approval, the backend returns an API key which the CLI stores. + */ +async function handleDeviceLogin(): Promise { + const tenantUrl = await resolveTenantUrl() + + let deviceCodeResponse: DeviceCodeResponse + try { + deviceCodeResponse = await requestDeviceCode(tenantUrl) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Failed to start login flow: ${message}`) + process.exit(1) + } + + const { userCode, deviceCode, expiresIn, interval } = deviceCodeResponse + + const verificationUrl = `${tenantUrl}/login/device` + console.log(`Opening browser at ${verificationUrl}`) + const formattedCode = + userCode.length === 8 ? `${userCode.slice(0, 4)}-${userCode.slice(4)}` : userCode + console.log(`\nEnter code: ${chalk.bold(formattedCode)}\n`) + openBrowser(verificationUrl) + + const loader = twirlLoader() + loader.start('Waiting for authorization...') + + // Handle Ctrl+C gracefully during polling + const onSigint = () => { + loader.stop() + console.log('Cancelled.') + process.exit(0) + } + process.on('SIGINT', onSigint) + + const deadline = Date.now() + expiresIn * 1000 + + try { + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)) + + const result = await pollDeviceToken(tenantUrl, deviceCode) + if (result.status === 'approved') { + loader.stop() + process.removeListener('SIGINT', onSigint) + + const source = await saveCredentials({ + apiKey: result.data.apiKey, + tenantUrl: result.data.tenantUrl, + }) + + console.log(chalk.green('✓') + ` Logged in to ${result.data.tenantUrl}`) + console.log(` API Key: ${result.data.apiKeyName}`) + console.log(` Credentials saved to ${source}.`) + return + } + } + + loader.stop() + console.error(chalk.red('✗') + ' Authorization timed out. Please try again.') + process.exit(1) + } catch (e) { + loader.stop() + const message = e instanceof Error ? e.message : String(e) + console.error(chalk.red('Error:') + ` Authorization failed: ${message}`) + process.exit(1) + } +} + +async function handleStatus(): Promise { + const result = await resolveCredentialSource() + if (!result) { + console.log('Not logged in.') + return + } + + console.log(`Logged in to ${result.credentials.tenantUrl}`) + console.log(` Source: ${result.source}`) + + // Validate credentials by making a lightweight API call + try { + const api = createApi(result.credentials.tenantUrl, result.credentials.apiKey) + await api.projects.listProjects() + console.log(` Status: ${chalk.green('valid')}`) + } catch { + console.log(` Status: ${chalk.red('invalid or expired')}`) + } +} + +async function handleLogout(): Promise { + const result = await clearCredentials() + + if (result.cleared) { + console.log('Logged out.') + console.log( + 'Note that your API keys are still valid. To prevent unauthorized access, revoke them in your QA Sphere account settings.' + ) + return + } + + // Check if credentials come from a non-clearable source + const source = await resolveCredentialSource() + if (source) { + const sourceLabels: Partial> = { + env_var: 'environment variables (QAS_TOKEN, QAS_URL)', + '.env': 'a .env file in the current directory', + '.qaspherecli': 'a .qaspherecli file', + } + const label = sourceLabels[source.source] || source.source + console.log(`Cannot log out: credentials are provided via ${label}.`) + console.log('Remove them manually to log out.') + console.log( + 'Note that your API keys are still valid. To prevent unauthorized access, revoke them in your QA Sphere account settings.' + ) + return + } + + console.log('Not logged in.') +} + +export const authCommand: CommandModule = { + command: 'auth', + describe: 'Manage authentication', + builder: (yargs: Argv) => + yargs + .command({ + command: 'login', + describe: 'Authenticate with QA Sphere', + builder: (yargs: Argv) => + yargs.option('api-key', { + type: 'boolean', + describe: 'Log in by entering an API key directly', + }), + handler: async (args: AuthLoginArgs) => { + ensureInteractive() + if (args['api-key']) { + await handleApiKeyLogin() + } else { + await handleDeviceLogin() + } + }, + } as CommandModule) + .command({ + command: 'status', + describe: 'Show current authentication status', + handler: async () => { + await handleStatus() + }, + }) + .command({ + command: 'logout', + describe: 'Clear stored credentials', + handler: async () => { + await handleLogout() + }, + }) + .demandCommand(1, ''), + handler: () => {}, +} diff --git a/src/commands/main.ts b/src/commands/main.ts index 81c3de0..f14baee 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,5 +1,6 @@ import yargs from 'yargs' import { ResultUploadCommandModule } from './resultUpload' +import { authCommand } from './auth' import { qasEnvs, qasEnvFile } from '../utils/env' import { CLI_VERSION } from '../utils/version' @@ -8,9 +9,11 @@ export const run = (args: string | string[]) => .usage( `$0 [options] -Required variables: ${qasEnvs.join(', ')} +Authenticate using: $0 auth login +Or set variables: ${qasEnvs.join(', ')} These should be either exported as env vars or defined in a ${qasEnvFile} file.` ) + .command(authCommand) .command(new ResultUploadCommandModule('junit-upload')) .command(new ResultUploadCommandModule('playwright-json-upload')) .command(new ResultUploadCommandModule('allure-upload')) diff --git a/src/commands/resultUpload.ts b/src/commands/resultUpload.ts index 8b8fd53..c93f82e 100644 --- a/src/commands/resultUpload.ts +++ b/src/commands/resultUpload.ts @@ -190,7 +190,7 @@ ${chalk.bold('Run name template placeholders:')} } handler = async (args: Arguments) => { - loadEnvs() + await loadEnvs() const handler = new ResultUploadCommandHandler(this.type, args) await handler.handle() } diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts new file mode 100644 index 0000000..2a1a043 --- /dev/null +++ b/src/tests/auth-e2e.spec.ts @@ -0,0 +1,560 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +const loginServiceUrl = 'https://login.qasphere.com' +const tenantUrl = 'https://acme.eu1.qasphere.com' +const testApiKey = 'tenantId.keyId.keyToken' +const testApiKeyName = 'My CLI Key' +const testEmail = 'user@example.com' + +// --- MSW handlers --- + +const checkTenantHandler = http.get(`${loginServiceUrl}/api/check-tenant`, ({ request }) => { + const url = new URL(request.url) + const name = url.searchParams.get('name') + if (!name || name === 'nonexistent') { + return HttpResponse.json({ message: 'Tenant not found' }, { status: 404 }) + } + return HttpResponse.json({ redirectUrl: `${tenantUrl}/login` }) +}) + +const deviceCodeHandler = (interval = 0, expiresIn = 900) => + http.post(`${tenantUrl}/api/auth/device/code`, () => { + return HttpResponse.json({ + userCode: 'ABCD1234', + deviceCode: 'long-random-device-code', + expiresIn, + interval, + }) + }) + +const projectsHandler = http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `ApiKey ${testApiKey}`) { + return HttpResponse.json({ data: [], total: 0 }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) +}) + +const server = setupServer(checkTenantHandler, projectsHandler) + +// --- Test setup --- + +let testHomeDir: string +let log: ReturnType +let err: ReturnType +const originalEnv = { ...process.env } + +function mockKeyringUnavailable() { + vi.doMock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + setPassword() { + throw new Error('Platform secure storage failure') + } + getPassword(): string { + throw new Error('Platform secure storage failure') + } + deletePassword() { + throw new Error('Platform secure storage failure') + } + }, + })) +} + +function mockKeyringAvailable(): Map { + const store = new Map() + vi.doMock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + private key: string + constructor(service: string, account: string) { + this.key = `${service}:${account}` + } + setPassword(password: string) { + store.set(this.key, password) + } + getPassword(): string { + const val = store.get(this.key) + if (val === undefined) throw new Error('No entry') + return val + } + deletePassword() { + if (!store.has(this.key)) throw new Error('No entry') + store.delete(this.key) + } + }, + })) + return store +} + +function mockPrompts(teamName: string, apiKey = '') { + vi.doMock('../utils/prompt', () => ({ + ensureInteractive: () => {}, + prompt: async () => teamName, + promptHidden: async () => apiKey, + })) +} + +function mockBrowser() { + vi.doMock('../utils/browser', () => ({ + openBrowser: () => {}, + })) +} + +function mockProcessExit() { + return vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) +} + +function credentialsFilePath() { + return join(testHomeDir, '.config', 'qasphere', 'credentials.json') +} + +// vi.doMock only affects future imports. Since each test sets up different mocks (different prompts, keyring available vs unavailable), +// we need resetModules() + dynamic import to get a fresh module tree that picks up the current test's mocks. +async function runCommand(args: string) { + vi.resetModules() + const { run: freshRun } = await import('../commands/main') + return freshRun(args.split(' ')) +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +beforeEach(() => { + testHomeDir = join( + tmpdir(), + `qas-cli-auth-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}` + ) + mkdirSync(testHomeDir, { recursive: true }) + + vi.doMock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testHomeDir } + }) + + delete process.env.QAS_TOKEN + delete process.env.QAS_URL + delete process.env.QAS_LOGIN_SERVICE_URL + + log = vi.spyOn(console, 'log').mockImplementation(() => {}) + err = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + server.events.removeAllListeners() + + process.env.QAS_TOKEN = originalEnv.QAS_TOKEN + process.env.QAS_URL = originalEnv.QAS_URL + process.env.QAS_LOGIN_SERVICE_URL = originalEnv.QAS_LOGIN_SERVICE_URL + if (!originalEnv.QAS_TOKEN) delete process.env.QAS_TOKEN + if (!originalEnv.QAS_URL) delete process.env.QAS_URL + if (!originalEnv.QAS_LOGIN_SERVICE_URL) delete process.env.QAS_LOGIN_SERVICE_URL + + if (existsSync(testHomeDir)) { + rmSync(testHomeDir, { recursive: true }) + } + + vi.doUnmock('node:os') + vi.doUnmock('@napi-rs/keyring') + vi.doUnmock('../utils/prompt') + vi.doUnmock('../utils/browser') + vi.restoreAllMocks() +}) + +// --- Tests --- + +describe('auth login --api-key lifecycle', () => { + test('login → status → logout → status', async () => { + mockKeyringUnavailable() + mockPrompts('acme', testApiKey) + + // Use an isolated directory so no .qaspherecli is found after logout + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + // Login + await runCommand('auth login --api-key') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + + // Status (valid) + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + + // Logout + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + + // Status after logout + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth login (device flow)', () => { + test('device flow login succeeds', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() + + server.use( + deviceCodeHandler(), + http.post(`${tenantUrl}/api/auth/device/token`, () => { + return HttpResponse.json({ + status: 'approved', + data: { + apiKey: testApiKey, + apiKeyName: testApiKeyName, + tenantUrl: tenantUrl, + email: testEmail, + }, + }) + }) + ) + + await runCommand('auth login') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('ABCD-1234')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining(testApiKeyName)) + }) + + test('device flow shows timeout on expiry', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() + const exit = mockProcessExit() + + // Use 0 interval and 0 expiresIn so the loop exits immediately + server.use( + deviceCodeHandler(0, 0), + http.post(`${tenantUrl}/api/auth/device/token`, () => { + return HttpResponse.json({ status: 'pending' }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization timed out')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow handles device code request failure', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() + const exit = mockProcessExit() + + server.use( + http.post(`${tenantUrl}/api/auth/device/code`, () => { + return HttpResponse.json({ message: 'Internal server error' }, { status: 500 }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Failed to start login flow')) + expect(exit).toHaveBeenCalledWith(1) + }) +}) + +describe('auth login error cases', () => { + test('check-tenant returns 404 for unknown team', async () => { + mockKeyringUnavailable() + mockPrompts('nonexistent', testApiKey) + const exit = mockProcessExit() + + await runCommand('auth login --api-key').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Could not find team')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('empty team name shows error', async () => { + mockKeyringUnavailable() + mockPrompts('', testApiKey) + const exit = mockProcessExit() + + await runCommand('auth login --api-key').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Team name is required')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('empty API key shows error', async () => { + mockKeyringUnavailable() + mockPrompts('acme', '') + const exit = mockProcessExit() + + await runCommand('auth login --api-key').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('API key is required')) + expect(exit).toHaveBeenCalledWith(1) + }) +}) + +describe('auth status credential sources', () => { + test('shows env_var source when env vars are set', async () => { + mockKeyringUnavailable() + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('env_var')) + }) + + test('shows .env source when .env file exists', async () => { + mockKeyringUnavailable() + + const envDir = join(testHomeDir, 'project') + mkdirSync(envDir, { recursive: true }) + writeFileSync(join(envDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + + const origCwd = process.cwd() + process.chdir(envDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) + } finally { + process.chdir(origCwd) + } + }) + + test('env vars take priority over .env file', async () => { + mockKeyringUnavailable() + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + const envDir = join(testHomeDir, 'project') + mkdirSync(envDir, { recursive: true }) + writeFileSync( + join(envDir, '.env'), + 'QAS_TOKEN=other-token\nQAS_URL=https://other.qasphere.com\n' + ) + + const origCwd = process.cwd() + process.chdir(envDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('env_var')) + } finally { + process.chdir(origCwd) + } + }) + + test('shows .qaspherecli source when file exists in directory tree', async () => { + mockKeyringUnavailable() + + const projectDir = join(testHomeDir, 'project') + const subDir = join(projectDir, 'sub', 'dir') + mkdirSync(subDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(subDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('shows invalid status when credentials are bad', async () => { + mockKeyringUnavailable() + process.env.QAS_TOKEN = 'bad-token' + process.env.QAS_URL = tenantUrl + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) + }) + + test('shows not logged in when no credentials found', async () => { + mockKeyringUnavailable() + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth logout edge cases', () => { + test('cannot log out when using env vars', async () => { + mockKeyringUnavailable() + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + await runCommand('auth logout') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) + }) + + test('shows not logged in when nothing to clear', async () => { + mockKeyringUnavailable() + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('second logout after file cleared shows not logged in', async () => { + mockKeyringUnavailable() + mockPrompts('acme', testApiKey) + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth login --api-key') + expect(existsSync(credentialsFilePath())).toBe(true) + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(existsSync(credentialsFilePath())).toBe(false) + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('credential storage (keyring unavailable)', () => { + test('saves to file with 0600 permissions', async () => { + mockKeyringUnavailable() + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + + const credFile = credentialsFilePath() + expect(existsSync(credFile)).toBe(true) + expect(statSync(credFile).mode & 0o777).toBe(0o600) + }) + + test('overwrites existing credentials on re-login', async () => { + mockKeyringUnavailable() + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + + // Verify first key is valid + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + expect(log).not.toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) + + // Re-login with different key + vi.doUnmock('../utils/prompt') + mockPrompts('acme', 'second-key') + + log.mockClear() + await runCommand('auth login --api-key') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + + // Verify the new key replaced the old one (projects endpoint rejects 'second-key') + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) + }) +}) + +describe('credential storage (keyring available)', () => { + test('saves to keyring when available', async () => { + const store = mockKeyringAvailable() + mockPrompts('acme', testApiKey) + + expect(store.size).toBe(0) + await runCommand('auth login --api-key') + + expect(store.size).toBe(1) + const value = Array.from(store.values())[0] + expect(JSON.parse(value)).toEqual({ tenantUrl, apiKey: testApiKey }) + expect(log).toHaveBeenCalledWith(expect.stringContaining('keyring')) + expect(existsSync(credentialsFilePath())).toBe(false) + }) + + test('logout clears keyring entry', async () => { + const store = mockKeyringAvailable() + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + expect(store.size).toBe(1) + const value = Array.from(store.values())[0] + expect(JSON.parse(value)).toEqual({ tenantUrl, apiKey: testApiKey }) + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(store.size).toBe(0) + }) + + test('second logout after keyring cleared shows not logged in', async () => { + mockKeyringAvailable() + mockPrompts('acme', testApiKey) + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth login --api-key') + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 0000000..b142a19 --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,15 @@ +import { execFile } from 'node:child_process' + +export function openBrowser(url: string): void { + switch (process.platform) { + case 'darwin': + execFile('open', [url]) + break + case 'win32': + execFile('cmd', ['/c', 'start', '', url]) + break + default: + execFile('xdg-open', [url]) + break + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index a6ba1e2..fdefd17 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1 +1,2 @@ export const REQUIRED_NODE_VERSION = '18.0.0' +export const LOGIN_SERVICE_URL = process.env.QAS_LOGIN_SERVICE_URL || 'https://login.qasphere.com' diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts new file mode 100644 index 0000000..6a63229 --- /dev/null +++ b/src/utils/credentials.ts @@ -0,0 +1,141 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' + +const KEYRING_SERVICE = 'qasphere-cli' +const KEYRING_ACCOUNT = 'credentials' +const CONFIG_DIR = join(homedir(), '.config', 'qasphere') +const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json') + +export interface StoredCredentials { + apiKey: string + tenantUrl: string +} + +export type CredentialSource = 'env_var' | '.env' | 'keyring' | 'credentials.json' | '.qaspherecli' + +interface KeyringEntry { + setPassword(password: string): void + getPassword(): string + deletePassword(): void +} + +type KeyringModule = { + Entry: new (service: string, account: string) => KeyringEntry +} + +async function loadKeyringModule(): Promise { + try { + return (await import('@napi-rs/keyring')) as KeyringModule + } catch { + // Import fails when the native binary is missing (e.g., Alpine/musl where + // the platform-specific @napi-rs/keyring-* package is not installed). + return null + } +} + +async function getKeyringEntry(): Promise { + const mod = await loadKeyringModule() + if (!mod) return null + try { + return new mod.Entry(KEYRING_SERVICE, KEYRING_ACCOUNT) + } catch { + // Entry construction fails when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} + +async function isKeyringAvailable(): Promise { + const mod = await loadKeyringModule() + if (!mod) return false + try { + const test = new mod.Entry(KEYRING_SERVICE, '__qas_keyring_test__') + test.setPassword('test') + test.getPassword() + test.deletePassword() + return true + } catch { + // Operations fail when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return false + } +} + +export async function saveCredentials(credentials: StoredCredentials): Promise { + const json = JSON.stringify(credentials) + + if (await isKeyringAvailable()) { + const entry = (await getKeyringEntry())! + entry.setPassword(json) + return 'keyring' + } + + // Fallback: write to file with restricted permissions + mkdirSync(CONFIG_DIR, { recursive: true }) + writeFileSync(CREDENTIALS_FILE, json, 'utf-8') + chmodSync(CREDENTIALS_FILE, 0o600) + return 'credentials.json' +} + +function isValidCredentials(obj: unknown): obj is StoredCredentials { + return ( + typeof obj === 'object' && + obj !== null && + typeof (obj as StoredCredentials).apiKey === 'string' && + typeof (obj as StoredCredentials).tenantUrl === 'string' + ) +} + +export async function loadCredentialsFromKeyring(): Promise { + const entry = await getKeyringEntry() + if (!entry) return null + + try { + const json = entry.getPassword() + const parsed: unknown = JSON.parse(json) + return isValidCredentials(parsed) ? parsed : null + } catch { + // Operations fail when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} + +export function loadCredentialsFromFile(): StoredCredentials | null { + if (!existsSync(CREDENTIALS_FILE)) return null + + try { + const json = readFileSync(CREDENTIALS_FILE, 'utf-8') + const parsed: unknown = JSON.parse(json) + return isValidCredentials(parsed) ? parsed : null + } catch { + // Invalid JSON or read error. Skip loading credentials. + return null + } +} + +export async function clearCredentials(): Promise<{ + cleared: boolean + source?: CredentialSource +}> { + // Try keyring first + const entry = await getKeyringEntry() + if (entry) { + try { + entry.getPassword() // Throws if no entry exists + entry.deletePassword() + return { cleared: true, source: 'keyring' } + } catch { + // No keyring entry or keyring unavailable, continue to file + } + } + + // Try file + if (existsSync(CREDENTIALS_FILE)) { + unlinkSync(CREDENTIALS_FILE) + return { cleared: true, source: 'credentials.json' } + } + + return { cleared: false } +} diff --git a/src/utils/env.ts b/src/utils/env.ts index 3410477..e475938 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -2,63 +2,104 @@ import { config, DotenvPopulateInput } from 'dotenv' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import chalk from 'chalk' +import { + loadCredentialsFromKeyring, + loadCredentialsFromFile, + type StoredCredentials, + type CredentialSource, +} from './credentials' export const qasEnvFile = '.qaspherecli' export const qasEnvs = ['QAS_TOKEN', 'QAS_URL'] -export function hasRequiredKeys(env: NodeJS.ProcessEnv | DotenvPopulateInput): boolean { - return qasEnvs.every((key) => key in env && env[key] !== 'undefined') +interface ResolvedCredentials { + credentials: StoredCredentials + source: CredentialSource } -export function loadEnvs(): void { - if (hasRequiredKeys(process.env)) { - return +/** + * Resolves the credential source without modifying process.env. + * Used by auth status/logout to report where credentials come from. + */ +export async function resolveCredentialSource(): Promise { + // 1. Environment variables + if (process.env.QAS_TOKEN && process.env.QAS_URL) { + return { + credentials: { apiKey: process.env.QAS_TOKEN, tenantUrl: process.env.QAS_URL }, + source: 'env_var', + } } - const fileEnvs: DotenvPopulateInput = {} - let dir = process.cwd() - + // 2. .env file in cwd const dotenvPath = join(process.cwd(), '.env') if (existsSync(dotenvPath)) { + const fileEnvs: DotenvPopulateInput = {} config({ path: dotenvPath, processEnv: fileEnvs }) - } - - if (!hasRequiredKeys(fileEnvs)) { - for (;;) { - const envPath = join(dir, qasEnvFile) - if (existsSync(envPath)) { - config({ path: envPath, processEnv: fileEnvs }) - break + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + credentials: { + apiKey: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + }, + source: '.env', } + } + } - const parentDir = dirname(dir) - if (parentDir === dir) { - // If the parent directory is the same as the current, we've reached the root - break - } + // 3. Keyring + const keyringCreds = await loadCredentialsFromKeyring() + if (keyringCreds) { + return { credentials: keyringCreds, source: 'keyring' } + } - dir = parentDir - } + // 4. ~/.config/qasphere/credentials.json + const fileCreds = loadCredentialsFromFile() + if (fileCreds) { + return { credentials: fileCreds, source: 'credentials.json' } } - const missingEnvs = [] - for (const env of qasEnvs) { - if (!(env in process.env)) { - const fileEnvValue = fileEnvs[env] - if (fileEnvValue && fileEnvValue !== 'undefined') { - process.env[env] = fileEnvValue - } else { - missingEnvs.push(env) + // 5. .qaspherecli file + let dir = process.cwd() + for (;;) { + const envPath = join(dir, qasEnvFile) + if (existsSync(envPath)) { + const fileEnvs: DotenvPopulateInput = {} + config({ path: envPath, processEnv: fileEnvs }) + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + credentials: { + apiKey: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + }, + source: '.qaspherecli', + } } + break } + + const parentDir = dirname(dir) + if (parentDir === dir) break + dir = parentDir } - if (missingEnvs.length == 0) { + return null +} + +export async function loadEnvs(): Promise { + const resolved = await resolveCredentialSource() + if (resolved) { + process.env.QAS_TOKEN = resolved.credentials.apiKey + process.env.QAS_URL = resolved.credentials.tenantUrl return } - console.log(chalk.red('Missing required environment variables: ') + missingEnvs.join(', ')) - console.log('\nPlease create a .qaspherecli file with the following content:') + console.log( + chalk.red('Missing required environment variables: ') + + qasEnvs.filter((k) => !process.env[k]).join(', ') + ) + console.log('\nYou can authenticate using:') + console.log(chalk.green(' qasphere auth login')) + console.log('\nOr create a .qaspherecli file with the following content:') console.log( chalk.green(` QAS_TOKEN=your_token diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000..9a71a86 --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,67 @@ +import { createInterface } from 'node:readline' + +export function ensureInteractive(): void { + if (!process.stdin.isTTY) { + console.error( + 'Error: This command requires an interactive terminal.\n' + + 'Use environment variables (QAS_TOKEN, QAS_URL) for non-interactive environments.' + ) + process.exit(1) + } +} + +export function prompt(question: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export function promptHidden(question: string): Promise { + return new Promise((resolve) => { + process.stdout.write(question) + const chars: string[] = [] + + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf-8') + + const onData = (char: string) => { + // Ctrl+C + if (char === '\u0003') { + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + process.stdout.write('\n') + process.exit(0) + } + + // Enter + if (char === '\r' || char === '\n') { + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + process.stdout.write('\n') + resolve(chars.join('')) + return + } + + // Backspace + if (char === '\u007F' || char === '\b') { + chars.pop() + return + } + + chars.push(char) + } + + process.stdin.on('data', onData) + }) +} From 43c66ed8e8b61ecc280180c82673ab7fe19c4345 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Wed, 8 Apr 2026 18:21:29 +0400 Subject: [PATCH 02/10] Address PR comments --- src/commands/auth.ts | 43 +++++++--- src/tests/auth-e2e.spec.ts | 170 ++++++++++++++++++++++++++++++++++--- src/utils/browser.ts | 12 ++- src/utils/credentials.ts | 43 ++++------ 4 files changed, 217 insertions(+), 51 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 3b44ee1..9f142f1 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -34,6 +34,16 @@ async function resolveTenantUrl(): Promise { } } +async function validateApiKey(tenantUrl: string, apiKey: string): Promise { + try { + const api = createApi(tenantUrl, apiKey) + await api.projects.listProjects() + return true + } catch { + return false + } +} + async function handleApiKeyLogin(): Promise { const tenantUrl = await resolveTenantUrl() const apiKey = await promptHidden('API Key: ') @@ -42,6 +52,11 @@ async function handleApiKeyLogin(): Promise { process.exit(1) } + if (!(await validateApiKey(tenantUrl, apiKey))) { + console.error(chalk.red('Error:') + ' Invalid API key.') + process.exit(1) + } + const source = await saveCredentials({ apiKey: apiKey, tenantUrl: tenantUrl }) console.log(chalk.green('✓') + ` Logged in to ${tenantUrl}`) console.log(` Credentials saved to ${source}.`) @@ -132,21 +147,28 @@ async function handleStatus(): Promise { console.log(`Logged in to ${result.credentials.tenantUrl}`) console.log(` Source: ${result.source}`) - // Validate credentials by making a lightweight API call - try { - const api = createApi(result.credentials.tenantUrl, result.credentials.apiKey) - await api.projects.listProjects() - console.log(` Status: ${chalk.green('valid')}`) - } catch { - console.log(` Status: ${chalk.red('invalid or expired')}`) - } + const valid = await validateApiKey(result.credentials.tenantUrl, result.credentials.apiKey) + console.log(` Status: ${valid ? chalk.green('valid') : chalk.red('invalid or expired')}`) } +const sourceLabels: Partial> = { + env_var: 'environment variables (QAS_TOKEN, QAS_URL)', + '.env': 'a .env file in the current directory', + '.qaspherecli': 'a .qaspherecli file', +} async function handleLogout(): Promise { const result = await clearCredentials() if (result.cleared) { console.log('Logged out.') + + // Warn if credentials are still available from another source + const remaining = await resolveCredentialSource() + if (remaining) { + const label = sourceLabels[remaining.source] || remaining.source + console.log(`Note: credentials are still available via ${label}.`) + } + console.log( 'Note that your API keys are still valid. To prevent unauthorized access, revoke them in your QA Sphere account settings.' ) @@ -156,11 +178,6 @@ async function handleLogout(): Promise { // Check if credentials come from a non-clearable source const source = await resolveCredentialSource() if (source) { - const sourceLabels: Partial> = { - env_var: 'environment variables (QAS_TOKEN, QAS_URL)', - '.env': 'a .env file in the current directory', - '.qaspherecli': 'a .qaspherecli file', - } const label = sourceLabels[source.source] || source.source console.log(`Cannot log out: credentials are provided via ${label}.`) console.log('Remove them manually to log out.') diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts index 2a1a043..c39e1b5 100644 --- a/src/tests/auth-e2e.spec.ts +++ b/src/tests/auth-e2e.spec.ts @@ -479,29 +479,36 @@ describe('credential storage (keyring unavailable)', () => { }) test('overwrites existing credentials on re-login', async () => { + const secondApiKey = 'tenantId.keyId2.keyToken2' + + // Accept both API keys for validation + server.use( + http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `ApiKey ${testApiKey}` || auth === `ApiKey ${secondApiKey}`) { + return HttpResponse.json({ data: [], total: 0 }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }) + ) + mockKeyringUnavailable() mockPrompts('acme', testApiKey) await runCommand('auth login --api-key') - // Verify first key is valid - log.mockClear() - await runCommand('auth status') - expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) - expect(log).not.toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) - - // Re-login with different key + // Re-login with different valid key vi.doUnmock('../utils/prompt') - mockPrompts('acme', 'second-key') + mockPrompts('acme', secondApiKey) log.mockClear() await runCommand('auth login --api-key') expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) - // Verify the new key replaced the old one (projects endpoint rejects 'second-key') + // Verify status uses the new key log.mockClear() await runCommand('auth status') - expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid or expired')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) }) }) @@ -557,4 +564,147 @@ describe('credential storage (keyring available)', () => { process.chdir(origCwd) } }) + + test('auth status shows keyring as source', async () => { + mockKeyringAvailable() + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining('keyring')) + }) +}) + +describe('credential resolution edge cases', () => { + test('partial env vars (only QAS_TOKEN) falls through to .qaspherecli', async () => { + mockKeyringUnavailable() + process.env.QAS_TOKEN = 'env-only-token' // Invalid token should fail assertions below if it were used + // QAS_URL intentionally not set — should not resolve as env_var + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth status') + expect(log).not.toHaveBeenCalledWith(expect.stringContaining('env_var')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + } finally { + process.chdir(origCwd) + } + }) + + test('corrupt credentials file warns and falls back gracefully', async () => { + mockKeyringUnavailable() + + // Write garbage to the credentials file + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), 'not valid json{{{') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not read credentials')) + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) + + test('credentials file with wrong shape warns and falls back gracefully', async () => { + mockKeyringUnavailable() + + // Write valid JSON but wrong shape + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), '{"foo": "bar"}') + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) + + const origCwd = process.cwd() + process.chdir(emptyDir) + try { + await runCommand('auth status') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid format')) + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth logout source labels', () => { + test('cannot log out when using .env file', async () => { + mockKeyringUnavailable() + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) + } finally { + process.chdir(origCwd) + } + }) + + test('cannot log out when using .qaspherecli file', async () => { + mockKeyringUnavailable() + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('logout warns when env vars still active after clearing keyring', async () => { + mockKeyringAvailable() + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + + // Set env vars that will persist after keyring is cleared + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(log).toHaveBeenCalledWith(expect.stringContaining('still available')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) + }) }) diff --git a/src/utils/browser.ts b/src/utils/browser.ts index b142a19..db1ea0e 100644 --- a/src/utils/browser.ts +++ b/src/utils/browser.ts @@ -1,15 +1,21 @@ import { execFile } from 'node:child_process' +const onError = (err: Error | null) => { + if (err) console.error('Could not open browser. Please visit the URL manually.') +} + export function openBrowser(url: string): void { + new URL(url) // Validate URL to prevent shell injection + switch (process.platform) { case 'darwin': - execFile('open', [url]) + execFile('open', [url], onError) break case 'win32': - execFile('cmd', ['/c', 'start', '', url]) + execFile('cmd', ['/c', 'start', '', url], onError) break default: - execFile('xdg-open', [url]) + execFile('xdg-open', [url], onError) break } } diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 6a63229..3b02074 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -46,35 +46,23 @@ async function getKeyringEntry(): Promise { } } -async function isKeyringAvailable(): Promise { - const mod = await loadKeyringModule() - if (!mod) return false - try { - const test = new mod.Entry(KEYRING_SERVICE, '__qas_keyring_test__') - test.setPassword('test') - test.getPassword() - test.deletePassword() - return true - } catch { - // Operations fail when the keyring daemon is unavailable - // (e.g., glibc Linux without D-Bus/Secret Service). - return false - } -} - export async function saveCredentials(credentials: StoredCredentials): Promise { const json = JSON.stringify(credentials) - if (await isKeyringAvailable()) { - const entry = (await getKeyringEntry())! - entry.setPassword(json) - return 'keyring' + const entry = await getKeyringEntry() + if (entry) { + try { + entry.setPassword(json) + return 'keyring' + } catch { + // Keyring operation failed, fall through to file + } } // Fallback: write to file with restricted permissions mkdirSync(CONFIG_DIR, { recursive: true }) - writeFileSync(CREDENTIALS_FILE, json, 'utf-8') - chmodSync(CREDENTIALS_FILE, 0o600) + writeFileSync(CREDENTIALS_FILE, json, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(CREDENTIALS_FILE, 0o600) // belt-and-suspenders for existing files return 'credentials.json' } @@ -108,9 +96,14 @@ export function loadCredentialsFromFile(): StoredCredentials | null { try { const json = readFileSync(CREDENTIALS_FILE, 'utf-8') const parsed: unknown = JSON.parse(json) - return isValidCredentials(parsed) ? parsed : null - } catch { - // Invalid JSON or read error. Skip loading credentials. + if (!isValidCredentials(parsed)) { + console.warn(`Warning: credentials file at ${CREDENTIALS_FILE} has invalid format.`) + return null + } + return parsed + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.warn(`Warning: could not read credentials file at ${CREDENTIALS_FILE}: ${message}`) return null } } From 9601435ddefb11eed69feb6072ac368d24bd7741 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Wed, 8 Apr 2026 18:37:06 +0400 Subject: [PATCH 03/10] Rename fields --- src/api/deviceAuth.ts | 4 ++-- src/commands/auth.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/deviceAuth.ts b/src/api/deviceAuth.ts index 03eb631..14f19bb 100644 --- a/src/api/deviceAuth.ts +++ b/src/api/deviceAuth.ts @@ -20,8 +20,8 @@ export interface DeviceTokenPendingResponse { export interface DeviceTokenApprovedResponse { status: 'approved' data: { - apiKey: string - apiKeyName: string + key: string + keyName: string tenantUrl: string email: string } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 9f142f1..117febc 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -115,12 +115,12 @@ async function handleDeviceLogin(): Promise { process.removeListener('SIGINT', onSigint) const source = await saveCredentials({ - apiKey: result.data.apiKey, + apiKey: result.data.key, tenantUrl: result.data.tenantUrl, }) console.log(chalk.green('✓') + ` Logged in to ${result.data.tenantUrl}`) - console.log(` API Key: ${result.data.apiKeyName}`) + console.log(` API Key: ${result.data.keyName}`) console.log(` Credentials saved to ${source}.`) return } From dd34f0a6170ac202c96efe196606fa9bc97ffc36 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Wed, 8 Apr 2026 18:42:10 +0400 Subject: [PATCH 04/10] Fix mocks --- src/tests/auth-e2e.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts index c39e1b5..23b2e76 100644 --- a/src/tests/auth-e2e.spec.ts +++ b/src/tests/auth-e2e.spec.ts @@ -220,8 +220,8 @@ describe('auth login (device flow)', () => { return HttpResponse.json({ status: 'approved', data: { - apiKey: testApiKey, - apiKeyName: testApiKeyName, + key: testApiKey, + keyName: testApiKeyName, tenantUrl: tenantUrl, email: testEmail, }, From a48a8caab83a3ed61fc2c31886c6afda74fc3b85 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Thu, 9 Apr 2026 14:07:12 +0400 Subject: [PATCH 05/10] Address feedbacks --- src/api/deviceAuth.ts | 21 ++++++++++++++++----- src/api/index.ts | 19 +++++++++++++------ src/api/utils.ts | 36 +++++++++++++++++++++++++++++------- src/commands/auth.ts | 4 ++-- src/tests/auth-e2e.spec.ts | 25 +++++++++++++++++++++++++ src/utils/credentials.ts | 2 +- 6 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/api/deviceAuth.ts b/src/api/deviceAuth.ts index 14f19bb..f277c1d 100644 --- a/src/api/deviceAuth.ts +++ b/src/api/deviceAuth.ts @@ -1,4 +1,11 @@ -import { withBaseUrl, withJson, withHttpRetry, jsonResponse } from './utils' +import { + withFetchMiddlewares, + withBaseUrl, + withJson, + withUserAgent, + withHttpRetry, + jsonResponse, +} from './utils' import { CLI_VERSION } from '../utils/version' import { LOGIN_SERVICE_URL } from '../utils/config' @@ -29,13 +36,19 @@ export interface DeviceTokenApprovedResponse { type DeviceTokenResponse = DeviceTokenPendingResponse | DeviceTokenApprovedResponse -const createFetcher = (baseUrl: string) => withHttpRetry(withJson(withBaseUrl(fetch, baseUrl))) +const createFetcher = (baseUrl: string) => + withFetchMiddlewares( + fetch, + withBaseUrl(baseUrl), + withUserAgent(CLI_VERSION), + withJson, + withHttpRetry + ) export async function checkTenant(teamName: string): Promise { const fetcher = createFetcher(LOGIN_SERVICE_URL) const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { method: 'GET', - headers: { 'User-Agent': `qas-cli/${CLI_VERSION}` }, }) const data = await jsonResponse(response) @@ -49,7 +62,6 @@ export async function requestDeviceCode(tenantUrl: string): Promise(response) } @@ -61,7 +73,6 @@ export async function pollDeviceToken( const fetcher = createFetcher(tenantUrl) const response = await fetcher('/api/auth/device/token', { method: 'POST', - headers: { 'User-Agent': `qas-cli/${CLI_VERSION}` }, body: JSON.stringify({ deviceCode }), }) return jsonResponse(response) diff --git a/src/api/index.ts b/src/api/index.ts index 105d1ef..a942f12 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,7 +3,13 @@ import { createFolderApi } from './folders' import { createProjectApi } from './projects' import { createRunApi } from './run' import { createTCaseApi } from './tcases' -import { withBaseUrl, withHeaders, withHttpRetry } from './utils' +import { + withFetchMiddlewares, + withBaseUrl, + withApiKey, + withUserAgent, + withHttpRetry, +} from './utils' import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { @@ -20,10 +26,11 @@ export type Api = ReturnType export const createApi = (baseUrl: string, apiKey: string) => getApi( - withHttpRetry( - withHeaders(withBaseUrl(fetch, baseUrl), { - Authorization: `ApiKey ${apiKey}`, - 'User-Agent': `qas-cli/${CLI_VERSION}`, - }) + withFetchMiddlewares( + fetch, + withBaseUrl(baseUrl), + withUserAgent(CLI_VERSION), + withApiKey(apiKey), + withHttpRetry ) ) diff --git a/src/api/utils.ts b/src/api/utils.ts index c114137..5c71f64 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,13 +1,25 @@ -export const withBaseUrl = (fetcher: typeof fetch, baseUrl: string): typeof fetch => { - return (input: URL | RequestInfo, init?: RequestInit | undefined) => { - if (typeof input === 'string') { - return fetcher(baseUrl + input, init) +type FetchMiddleware = (fetcher: typeof fetch) => typeof fetch + +// TODO: Each middleware adds a frame to the stack trace. V8 defaults to 10 frames (Error.stackTraceLimit). +// With too many middlewares, the call site gets truncated from the stack, making it hard to identify where the request originated. +// Currently at ~4 middlewares which fits within the limit. +export const withFetchMiddlewares = ( + fetcher: typeof fetch, + ...middlewares: FetchMiddleware[] +): typeof fetch => middlewares.reduce((f, mw) => mw(f), fetcher) + +export const withBaseUrl = + (baseUrl: string): FetchMiddleware => + (fetcher: typeof fetch): typeof fetch => { + return (input: URL | RequestInfo, init?: RequestInit | undefined) => { + if (typeof input === 'string') { + return fetcher(baseUrl + input, init) + } + return fetcher(input, init) } - return fetcher(input, init) } -} -export const withJson = (fetcher: typeof fetch): typeof fetch => { +export const withJson: FetchMiddleware = (fetcher) => { const JSON_CONFIG: RequestInit = { headers: { Accept: 'application/json', @@ -41,6 +53,16 @@ export const withHeaders = ( } } +export const withUserAgent = + (version: string): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { 'User-Agent': `qas-cli/${version}` }) + +export const withApiKey = + (apiKey: string): FetchMiddleware => + (fetcher) => + withHeaders(fetcher, { Authorization: `ApiKey ${apiKey}` }) + export const jsonResponse = async (response: Response): Promise => { const json = await response.json() if (response.ok) { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 117febc..c8f0417 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -3,13 +3,13 @@ import chalk from 'chalk' import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' import { openBrowser } from '../utils/browser' import { twirlLoader } from '../utils/misc' -import { saveCredentials, clearCredentials, CredentialSource } from '../utils/credentials' +import { saveCredentials, clearCredentials, type CredentialSource } from '../utils/credentials' import { createApi } from '../api' import { checkTenant, requestDeviceCode, pollDeviceToken, - DeviceCodeResponse, + type DeviceCodeResponse, } from '../api/deviceAuth' import { resolveCredentialSource } from '../utils/env' diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts index 23b2e76..555bd33 100644 --- a/src/tests/auth-e2e.spec.ts +++ b/src/tests/auth-e2e.spec.ts @@ -466,6 +466,31 @@ describe('auth logout edge cases', () => { }) }) +describe('credential storage (keyring setPassword failure)', () => { + test('falls back to file when keyring setPassword throws', async () => { + // Keyring module loads and Entry construction works, but setPassword throws + vi.doMock('@napi-rs/keyring', () => ({ + Entry: class MockEntry { + setPassword() { + throw new Error('org.freedesktop.DBus.Error.ServiceUnknown') + } + getPassword(): string { + throw new Error('org.freedesktop.DBus.Error.ServiceUnknown') + } + deletePassword() { + throw new Error('org.freedesktop.DBus.Error.ServiceUnknown') + } + }, + })) + mockPrompts('acme', testApiKey) + + await runCommand('auth login --api-key') + + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(existsSync(credentialsFilePath())).toBe(true) + }) +}) + describe('credential storage (keyring unavailable)', () => { test('saves to file with 0600 permissions', async () => { mockKeyringUnavailable() diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 3b02074..a4b4f27 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -60,7 +60,7 @@ export async function saveCredentials(credentials: StoredCredentials): Promise Date: Thu, 9 Apr 2026 17:31:04 +0400 Subject: [PATCH 06/10] Few improvements --- src/api/deviceAuth.ts | 4 ++-- src/commands/auth.ts | 36 +++++++++++++++++++++++++++++------- src/utils/credentials.ts | 27 ++++++--------------------- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/api/deviceAuth.ts b/src/api/deviceAuth.ts index f277c1d..367bb06 100644 --- a/src/api/deviceAuth.ts +++ b/src/api/deviceAuth.ts @@ -45,7 +45,7 @@ const createFetcher = (baseUrl: string) => withHttpRetry ) -export async function checkTenant(teamName: string): Promise { +export async function checkTenant(teamName: string): Promise<{ tenantUrl: string }> { const fetcher = createFetcher(LOGIN_SERVICE_URL) const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { method: 'GET', @@ -55,7 +55,7 @@ export async function checkTenant(teamName: string): Promise { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index c8f0417..27a5e22 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -3,7 +3,13 @@ import chalk from 'chalk' import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' import { openBrowser } from '../utils/browser' import { twirlLoader } from '../utils/misc' -import { saveCredentials, clearCredentials, type CredentialSource } from '../utils/credentials' +import { + saveCredentials, + clearCredentials, + loadCredentialsFromKeyring, + loadCredentialsFromFile, + type CredentialSource, +} from '../utils/credentials' import { createApi } from '../api' import { checkTenant, @@ -25,8 +31,8 @@ async function resolveTenantUrl(): Promise { } try { - const result = await checkTenant(teamName) - return result.redirectUrl + const { tenantUrl } = await checkTenant(teamName) + return tenantUrl } catch (e) { const message = e instanceof Error ? e.message : String(e) console.error(chalk.red('Error:') + ` Could not find team "${teamName}": ${message}`) @@ -46,7 +52,7 @@ async function validateApiKey(tenantUrl: string, apiKey: string): Promise { const tenantUrl = await resolveTenantUrl() - const apiKey = await promptHidden('API Key: ') + const apiKey = await promptHidden(`API Key ${chalk.gray('(Input Hidden)')}: `) if (!apiKey) { console.error(chalk.red('Error:') + ' API key is required.') process.exit(1) @@ -156,10 +162,26 @@ const sourceLabels: Partial> = { '.env': 'a .env file in the current directory', '.qaspherecli': 'a .qaspherecli file', } +async function findClearableSource(): Promise<'keyring' | 'credentials.json' | null> { + if (await loadCredentialsFromKeyring()) return 'keyring' + if (loadCredentialsFromFile()) return 'credentials.json' + return null +} + async function handleLogout(): Promise { - const result = await clearCredentials() + const clearable = await findClearableSource() + + if (clearable) { + try { + await clearCredentials(clearable) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.error( + chalk.red('Error:') + ` Could not clear credentials from ${clearable}: ${message}` + ) + process.exit(1) + } - if (result.cleared) { console.log('Logged out.') // Warn if credentials are still available from another source @@ -175,7 +197,7 @@ async function handleLogout(): Promise { return } - // Check if credentials come from a non-clearable source + // No clearable source — check if credentials come from a non-clearable source const source = await resolveCredentialSource() if (source) { const label = sourceLabels[source.source] || source.source diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index a4b4f27..0f9f53a 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -108,27 +108,12 @@ export function loadCredentialsFromFile(): StoredCredentials | null { } } -export async function clearCredentials(): Promise<{ - cleared: boolean - source?: CredentialSource -}> { - // Try keyring first - const entry = await getKeyringEntry() - if (entry) { - try { - entry.getPassword() // Throws if no entry exists - entry.deletePassword() - return { cleared: true, source: 'keyring' } - } catch { - // No keyring entry or keyring unavailable, continue to file - } - } - - // Try file - if (existsSync(CREDENTIALS_FILE)) { +export async function clearCredentials(source: 'keyring' | 'credentials.json'): Promise { + if (source === 'keyring') { + const entry = await getKeyringEntry() + if (!entry) throw new Error('Keyring is not available') + entry.deletePassword() + } else { unlinkSync(CREDENTIALS_FILE) - return { cleared: true, source: 'credentials.json' } } - - return { cleared: false } } From dda926eb74bdce59e5f7485aa96c3fe532e0e179 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Thu, 9 Apr 2026 18:27:27 +0400 Subject: [PATCH 07/10] Clean up a bit --- src/commands/auth.ts | 25 +++++++------------------ src/utils/credentials.ts | 7 +++++-- src/utils/env.ts | 29 +++++++++++++++++------------ 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 27a5e22..460da96 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -3,13 +3,7 @@ import chalk from 'chalk' import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' import { openBrowser } from '../utils/browser' import { twirlLoader } from '../utils/misc' -import { - saveCredentials, - clearCredentials, - loadCredentialsFromKeyring, - loadCredentialsFromFile, - type CredentialSource, -} from '../utils/credentials' +import { saveCredentials, clearCredentials, type CredentialSource } from '../utils/credentials' import { createApi } from '../api' import { checkTenant, @@ -17,7 +11,7 @@ import { pollDeviceToken, type DeviceCodeResponse, } from '../api/deviceAuth' -import { resolveCredentialSource } from '../utils/env' +import { resolveCredentialSource, resolvePersistedCredentialSource } from '../utils/env' interface AuthLoginArgs { 'api-key'?: boolean @@ -162,26 +156,21 @@ const sourceLabels: Partial> = { '.env': 'a .env file in the current directory', '.qaspherecli': 'a .qaspherecli file', } -async function findClearableSource(): Promise<'keyring' | 'credentials.json' | null> { - if (await loadCredentialsFromKeyring()) return 'keyring' - if (loadCredentialsFromFile()) return 'credentials.json' - return null -} async function handleLogout(): Promise { - const clearable = await findClearableSource() + const clearableSource = await resolvePersistedCredentialSource() - if (clearable) { + if (clearableSource) { try { - await clearCredentials(clearable) + await clearCredentials(clearableSource.source) } catch (e) { const message = e instanceof Error ? e.message : String(e) console.error( - chalk.red('Error:') + ` Could not clear credentials from ${clearable}: ${message}` + chalk.red('Error:') + + ` Could not clear credentials from ${clearableSource.source}: ${message}` ) process.exit(1) } - console.log('Logged out.') // Warn if credentials are still available from another source diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 0f9f53a..b95c03b 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -108,12 +108,15 @@ export function loadCredentialsFromFile(): StoredCredentials | null { } } -export async function clearCredentials(source: 'keyring' | 'credentials.json'): Promise { +export async function clearCredentials(source: CredentialSource): Promise { if (source === 'keyring') { const entry = await getKeyringEntry() if (!entry) throw new Error('Keyring is not available') entry.deletePassword() - } else { + return + } else if (source === 'credentials.json') { unlinkSync(CREDENTIALS_FILE) + return } + throw new Error(`Cannot clear credentials from ${source}`) } diff --git a/src/utils/env.ts b/src/utils/env.ts index e475938..4b9f9c8 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -46,19 +46,11 @@ export async function resolveCredentialSource(): Promise { + const keyringCreds = await loadCredentialsFromKeyring() + if (keyringCreds) { + return { credentials: keyringCreds, source: 'keyring' } + } + + const fileCreds = loadCredentialsFromFile() + if (fileCreds) { + return { credentials: fileCreds, source: 'credentials.json' } + } + return null +} + export async function loadEnvs(): Promise { const resolved = await resolveCredentialSource() if (resolved) { From a22c113fc585ab355e1ee0d26e63d54aa62a568b Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Thu, 9 Apr 2026 18:43:13 +0400 Subject: [PATCH 08/10] Import type --- src/commands/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 460da96..a4e2fd1 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,4 +1,4 @@ -import { Argv, CommandModule } from 'yargs' +import type { Argv, CommandModule } from 'yargs' import chalk from 'chalk' import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' import { openBrowser } from '../utils/browser' From 335c096f85a17f67d4088bd654215cec74051dd5 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Fri, 10 Apr 2026 17:41:07 +0400 Subject: [PATCH 09/10] Refactor code to use standard oauth implementation. --- CLAUDE.md | 4 +- README.md | 6 - src/api/deviceAuth.ts | 79 --- src/api/index.ts | 13 +- src/api/oauth.ts | 140 +++++ src/api/utils.ts | 13 +- src/commands/auth.ts | 178 +++--- src/commands/main.ts | 2 +- src/commands/resultUpload.ts | 11 +- src/tests/auth-e2e.spec.ts | 593 +++++++++++++----- src/utils/credentials.ts | 122 ---- src/utils/credentials/index.ts | 10 + src/utils/credentials/keyring.ts | 34 + src/utils/credentials/resolvers.ts | 191 ++++++ src/utils/credentials/storage.ts | 78 +++ src/utils/credentials/types.ts | 35 ++ src/utils/env.ts | 124 ---- src/utils/prompt.ts | 42 -- .../ResultUploadCommandHandler.ts | 17 +- src/utils/result-upload/ResultUploader.ts | 7 +- 20 files changed, 1054 insertions(+), 645 deletions(-) delete mode 100644 src/api/deviceAuth.ts create mode 100644 src/api/oauth.ts delete mode 100644 src/utils/credentials.ts create mode 100644 src/utils/credentials/index.ts create mode 100644 src/utils/credentials/keyring.ts create mode 100644 src/utils/credentials/resolvers.ts create mode 100644 src/utils/credentials/storage.ts create mode 100644 src/utils/credentials/types.ts delete mode 100644 src/utils/env.ts diff --git a/CLAUDE.md b/CLAUDE.md index a05131b..bc8b6b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,8 @@ The upload flow has two stages handled by two classes, with a shared `MarkerPars Composable fetch wrappers using higher-order functions: -- `utils.ts` — `withBaseUrl`, `withApiKey`, `withJson` decorators that wrap `fetch` -- `index.ts` — `createApi(baseUrl, apiKey)` assembles the API client from sub-modules +- `utils.ts` — `withBaseUrl`, `withAuth`, `withJson` decorators that wrap `fetch` +- `index.ts` — `createApi(baseUrl, token, authType)` assembles the API client from sub-modules - Sub-modules: `projects.ts`, `run.ts`, `tcases.ts`, `file.ts` ### Configuration (src/utils/) diff --git a/README.md b/README.md index 4c018b9..a630d02 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,6 @@ qasphere auth login This opens your browser to complete authentication and securely stores your credentials in the system keyring. If a keyring is not available, credentials are stored in `~/.config/qasphere/credentials.json` with restricted file permissions. -You can also log in by directly providing an API key: - -```bash -qasphere auth login --api-key -``` - ### Other auth commands ```bash diff --git a/src/api/deviceAuth.ts b/src/api/deviceAuth.ts deleted file mode 100644 index 367bb06..0000000 --- a/src/api/deviceAuth.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - withFetchMiddlewares, - withBaseUrl, - withJson, - withUserAgent, - withHttpRetry, - jsonResponse, -} from './utils' -import { CLI_VERSION } from '../utils/version' -import { LOGIN_SERVICE_URL } from '../utils/config' - -export interface CheckTenantResponse { - redirectUrl: string -} - -export interface DeviceCodeResponse { - userCode: string - deviceCode: string - expiresIn: number - interval: number -} - -export interface DeviceTokenPendingResponse { - status: 'pending' -} - -export interface DeviceTokenApprovedResponse { - status: 'approved' - data: { - key: string - keyName: string - tenantUrl: string - email: string - } -} - -type DeviceTokenResponse = DeviceTokenPendingResponse | DeviceTokenApprovedResponse - -const createFetcher = (baseUrl: string) => - withFetchMiddlewares( - fetch, - withBaseUrl(baseUrl), - withUserAgent(CLI_VERSION), - withJson, - withHttpRetry - ) - -export async function checkTenant(teamName: string): Promise<{ tenantUrl: string }> { - const fetcher = createFetcher(LOGIN_SERVICE_URL) - const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { - method: 'GET', - }) - const data = await jsonResponse(response) - - // The check-tenant endpoint returns a redirect URL (e.g. http://tenant.localhost:5173/login). - // Extract just the origin for use as the API base URL. - const origin = new URL(data.redirectUrl).origin - return { tenantUrl: origin } -} - -export async function requestDeviceCode(tenantUrl: string): Promise { - const fetcher = createFetcher(tenantUrl) - const response = await fetcher('/api/auth/device/code', { - method: 'POST', - }) - return jsonResponse(response) -} - -export async function pollDeviceToken( - tenantUrl: string, - deviceCode: string -): Promise { - const fetcher = createFetcher(tenantUrl) - const response = await fetcher('/api/auth/device/token', { - method: 'POST', - body: JSON.stringify({ deviceCode }), - }) - return jsonResponse(response) -} diff --git a/src/api/index.ts b/src/api/index.ts index a942f12..cf04686 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,13 +3,8 @@ import { createFolderApi } from './folders' import { createProjectApi } from './projects' import { createRunApi } from './run' import { createTCaseApi } from './tcases' -import { - withFetchMiddlewares, - withBaseUrl, - withApiKey, - withUserAgent, - withHttpRetry, -} from './utils' +import { withFetchMiddlewares, withBaseUrl, withAuth, withUserAgent, withHttpRetry } from './utils' +import type { AuthType } from './utils' import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { @@ -24,13 +19,13 @@ const getApi = (fetcher: typeof fetch) => { export type Api = ReturnType -export const createApi = (baseUrl: string, apiKey: string) => +export const createApi = (baseUrl: string, token: string, authType: AuthType = 'apikey') => getApi( withFetchMiddlewares( fetch, withBaseUrl(baseUrl), withUserAgent(CLI_VERSION), - withApiKey(apiKey), + withAuth(token, authType), withHttpRetry ) ) diff --git a/src/api/oauth.ts b/src/api/oauth.ts new file mode 100644 index 0000000..1f2be13 --- /dev/null +++ b/src/api/oauth.ts @@ -0,0 +1,140 @@ +import { + withFetchMiddlewares, + withBaseUrl, + withJson, + withUserAgent, + withHttpRetry, + jsonResponse, +} from './utils' +import { CLI_VERSION } from '../utils/version' +import { LOGIN_SERVICE_URL } from '../utils/config' + +const OAUTH_CLIENT_ID = 'qas-cli' +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' + +// --- Types --- + +export interface CheckTenantResponse { + redirectUrl: string +} + +export interface OAuthDeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number +} + +export interface OAuthTokenResponse { + access_token: string + token_type: string + expires_in: number + refresh_token: string +} + +export interface OAuthErrorResponse { + error: string + error_description?: string +} + +export type OAuthTokenResult = + | { ok: true; data: OAuthTokenResponse } + | { ok: false; error: OAuthErrorResponse } + +// --- Helpers --- + +const createFetcher = (baseUrl: string) => + withFetchMiddlewares( + fetch, + withBaseUrl(baseUrl), + withUserAgent(CLI_VERSION), + withJson, + withHttpRetry + ) + +async function oauthErrorResponse(response: Response): Promise { + try { + const json = (await response.json()) as OAuthErrorResponse + return { + error: json.error || 'unknown_error', + error_description: json.error_description || response.statusText, + } + } catch { + return { error: 'unknown_error', error_description: response.statusText } + } +} + +// --- API functions --- + +export async function checkTenant(teamName: string): Promise<{ tenantUrl: string }> { + const fetcher = createFetcher(LOGIN_SERVICE_URL) + const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { + method: 'GET', + }) + const data = await jsonResponse(response) + + // The check-tenant endpoint returns a redirect URL (e.g. http://tenant.localhost:5173/login). + // Extract just the origin for use as the API base URL. + const origin = new URL(data.redirectUrl).origin + return { tenantUrl: origin } +} + +export async function requestDeviceCode(tenantUrl: string): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/device/code', { + method: 'POST', + body: JSON.stringify({ client_id: OAUTH_CLIENT_ID }), + }) + + if (!response.ok) { + const err = await oauthErrorResponse(response) + throw new Error(err.error_description || err.error) + } + + return (await response.json()) as OAuthDeviceCodeResponse +} + +export async function pollDeviceToken( + tenantUrl: string, + deviceCode: string +): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/token', { + method: 'POST', + body: JSON.stringify({ + grant_type: DEVICE_CODE_GRANT_TYPE, + client_id: OAUTH_CLIENT_ID, + device_code: deviceCode, + }), + }) + + if (response.ok) { + return { ok: true, data: (await response.json()) as OAuthTokenResponse } + } + + const error = await oauthErrorResponse(response) + return { ok: false, error } +} + +export async function refreshAccessToken( + tenantUrl: string, + refreshToken: string +): Promise { + const fetcher = createFetcher(tenantUrl) + const response = await fetcher('/api/oauth/token', { + method: 'POST', + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const err = await oauthErrorResponse(response) + throw new Error(err.error_description || err.error) + } + + return (await response.json()) as OAuthTokenResponse +} diff --git a/src/api/utils.ts b/src/api/utils.ts index 5c71f64..ffc1306 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -11,9 +11,10 @@ export const withFetchMiddlewares = ( export const withBaseUrl = (baseUrl: string): FetchMiddleware => (fetcher: typeof fetch): typeof fetch => { + const normalized = baseUrl.replace(/\/+$/, '') return (input: URL | RequestInfo, init?: RequestInit | undefined) => { if (typeof input === 'string') { - return fetcher(baseUrl + input, init) + return fetcher(normalized + input, init) } return fetcher(input, init) } @@ -58,10 +59,14 @@ export const withUserAgent = (fetcher) => withHeaders(fetcher, { 'User-Agent': `qas-cli/${version}` }) -export const withApiKey = - (apiKey: string): FetchMiddleware => +export type AuthType = 'apikey' | 'bearer' + +export const withAuth = + (token: string, authType: AuthType): FetchMiddleware => (fetcher) => - withHeaders(fetcher, { Authorization: `ApiKey ${apiKey}` }) + withHeaders(fetcher, { + Authorization: authType === 'bearer' ? `Bearer ${token}` : `ApiKey ${token}`, + }) export const jsonResponse = async (response: Response): Promise => { const json = await response.json() diff --git a/src/commands/auth.ts b/src/commands/auth.ts index a4e2fd1..7edde3f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,21 +1,23 @@ import type { Argv, CommandModule } from 'yargs' import chalk from 'chalk' -import { ensureInteractive, prompt, promptHidden } from '../utils/prompt' +import { ensureInteractive, prompt } from '../utils/prompt' import { openBrowser } from '../utils/browser' import { twirlLoader } from '../utils/misc' -import { saveCredentials, clearCredentials, type CredentialSource } from '../utils/credentials' +import { + saveCredentials, + clearCredentials, + resolveCredentialSource, + resolvePersistedCredentialSource, + refreshIfNeeded, + type CredentialSource, +} from '../utils/credentials' import { createApi } from '../api' import { checkTenant, requestDeviceCode, pollDeviceToken, - type DeviceCodeResponse, -} from '../api/deviceAuth' -import { resolveCredentialSource, resolvePersistedCredentialSource } from '../utils/env' - -interface AuthLoginArgs { - 'api-key'?: boolean -} + type OAuthDeviceCodeResponse, +} from '../api/oauth' async function resolveTenantUrl(): Promise { const teamName = await prompt('Team name: ') @@ -34,47 +36,19 @@ async function resolveTenantUrl(): Promise { } } -async function validateApiKey(tenantUrl: string, apiKey: string): Promise { - try { - const api = createApi(tenantUrl, apiKey) - await api.projects.listProjects() - return true - } catch { - return false - } -} - -async function handleApiKeyLogin(): Promise { - const tenantUrl = await resolveTenantUrl() - const apiKey = await promptHidden(`API Key ${chalk.gray('(Input Hidden)')}: `) - if (!apiKey) { - console.error(chalk.red('Error:') + ' API key is required.') - process.exit(1) - } - - if (!(await validateApiKey(tenantUrl, apiKey))) { - console.error(chalk.red('Error:') + ' Invalid API key.') - process.exit(1) - } - - const source = await saveCredentials({ apiKey: apiKey, tenantUrl: tenantUrl }) - console.log(chalk.green('✓') + ` Logged in to ${tenantUrl}`) - console.log(` Credentials saved to ${source}.`) -} - /** - * Device Authorization Grant flow (RFC 8628). + * OAuth 2.0 Device Authorization Grant flow (RFC 8628). * * 1. CLI requests a device code and user code from the tenant backend. - * 2. CLI displays the user code and opens the browser to the verification URL. - * 3. The user enters the code in the browser and approves the device. - * 4. CLI polls the tenant backend until the user approves or the code expires. - * 5. On approval, the backend returns an API key which the CLI stores. + * 2. CLI opens the browser to the verification URL (with pre-filled code). + * 3. The user approves the device in the browser. + * 4. CLI polls the token endpoint until the user approves or the code expires. + * 5. On approval, the backend returns access + refresh tokens which the CLI stores. */ async function handleDeviceLogin(): Promise { const tenantUrl = await resolveTenantUrl() - let deviceCodeResponse: DeviceCodeResponse + let deviceCodeResponse: OAuthDeviceCodeResponse try { deviceCodeResponse = await requestDeviceCode(tenantUrl) } catch (e) { @@ -83,14 +57,16 @@ async function handleDeviceLogin(): Promise { process.exit(1) } - const { userCode, deviceCode, expiresIn, interval } = deviceCodeResponse + const { device_code, user_code, verification_uri, verification_uri_complete, expires_in } = + deviceCodeResponse + let currentInterval = deviceCodeResponse.interval - const verificationUrl = `${tenantUrl}/login/device` - console.log(`Opening browser at ${verificationUrl}`) - const formattedCode = - userCode.length === 8 ? `${userCode.slice(0, 4)}-${userCode.slice(4)}` : userCode - console.log(`\nEnter code: ${chalk.bold(formattedCode)}\n`) - openBrowser(verificationUrl) + console.log('Opening browser to authorize...') + const normalizedCode = user_code.replace('-', '') + const url = verification_uri_complete || `${verification_uri}?code=${normalizedCode}` + console.log(`\nIf the browser didn't open, visit:\n ${url}\n`) + openBrowser(url) + console.log(`Verify the code displayed in the browser: ${chalk.bold(normalizedCode)}\n`) const loader = twirlLoader() loader.start('Waiting for authorization...') @@ -103,31 +79,64 @@ async function handleDeviceLogin(): Promise { } process.on('SIGINT', onSigint) - const deadline = Date.now() + expiresIn * 1000 + const deadline = Date.now() + expires_in * 1000 try { while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, interval * 1000)) + await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000)) + + const result = await pollDeviceToken(tenantUrl, device_code) - const result = await pollDeviceToken(tenantUrl, deviceCode) - if (result.status === 'approved') { + if (result.ok) { loader.stop() process.removeListener('SIGINT', onSigint) const source = await saveCredentials({ - apiKey: result.data.key, - tenantUrl: result.data.tenantUrl, + type: 'oauth', + accessToken: result.data.access_token, + refreshToken: result.data.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + result.data.expires_in * 1000).toISOString(), + tenantUrl, }) - console.log(chalk.green('✓') + ` Logged in to ${result.data.tenantUrl}`) - console.log(` API Key: ${result.data.keyName}`) + console.log(chalk.green('\u2713') + ` Logged in to ${tenantUrl}`) console.log(` Credentials saved to ${source}.`) return } + + // Handle OAuth error responses + switch (result.error.error) { + case 'authorization_pending': + // Keep polling + break + case 'slow_down': + currentInterval += 5 + break + case 'access_denied': + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error(chalk.red('\u2717') + ' Authorization denied by user.') + process.exit(1) + break // unreachable, but satisfies linter + case 'expired_token': + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error(chalk.red('\u2717') + ' Authorization timed out. Please try again.') + process.exit(1) + break // unreachable + default: + loader.stop() + process.removeListener('SIGINT', onSigint) + console.error( + chalk.red('Error:') + + ` Authorization failed: ${result.error.error_description || result.error.error}` + ) + process.exit(1) + } } loader.stop() - console.error(chalk.red('✗') + ' Authorization timed out. Please try again.') + console.error(chalk.red('\u2717') + ' Authorization timed out. Please try again.') process.exit(1) } catch (e) { loader.stop() @@ -138,17 +147,40 @@ async function handleDeviceLogin(): Promise { } async function handleStatus(): Promise { - const result = await resolveCredentialSource() + let result = await resolveCredentialSource() if (!result) { console.log('Not logged in.') return } - console.log(`Logged in to ${result.credentials.tenantUrl}`) + // Refresh OAuth tokens if expired before validating + if (result.authType === 'bearer') { + result = await refreshIfNeeded(result) + } + + const tenantUrl = result.authType === 'bearer' ? result.credentials.tenantUrl : result.tenantUrl + console.log(`Logged in to ${tenantUrl}`) console.log(` Source: ${result.source}`) - const valid = await validateApiKey(result.credentials.tenantUrl, result.credentials.apiKey) - console.log(` Status: ${valid ? chalk.green('valid') : chalk.red('invalid or expired')}`) + const token = result.authType === 'bearer' ? result.credentials.accessToken : result.token + try { + const api = createApi(tenantUrl, token, result.authType) + await api.projects.listProjects() + console.log(` Status: ${chalk.green('valid')}`) + } catch { + console.log(` Status: ${chalk.red('invalid or expired')}`) + } + + if (result.authType === 'bearer') { + const expiresAt = new Date(result.credentials.accessTokenExpiresAt) + const remainingMs = expiresAt.getTime() - Date.now() + if (remainingMs > 0) { + const minutes = Math.floor(remainingMs / 60_000) + console.log(` Access token expires: in ${minutes} minute${minutes !== 1 ? 's' : ''}`) + } else { + console.log(` Access token expires: ${chalk.yellow('expired (will refresh on next use)')}`) + } + } } const sourceLabels: Partial> = { @@ -181,7 +213,7 @@ async function handleLogout(): Promise { } console.log( - 'Note that your API keys are still valid. To prevent unauthorized access, revoke them in your QA Sphere account settings.' + 'Note: your authorization is still active on the server. To revoke it, visit your QA Sphere account settings.' ) return } @@ -192,9 +224,6 @@ async function handleLogout(): Promise { const label = sourceLabels[source.source] || source.source console.log(`Cannot log out: credentials are provided via ${label}.`) console.log('Remove them manually to log out.') - console.log( - 'Note that your API keys are still valid. To prevent unauthorized access, revoke them in your QA Sphere account settings.' - ) return } @@ -209,20 +238,11 @@ export const authCommand: CommandModule = { .command({ command: 'login', describe: 'Authenticate with QA Sphere', - builder: (yargs: Argv) => - yargs.option('api-key', { - type: 'boolean', - describe: 'Log in by entering an API key directly', - }), - handler: async (args: AuthLoginArgs) => { + handler: async () => { ensureInteractive() - if (args['api-key']) { - await handleApiKeyLogin() - } else { - await handleDeviceLogin() - } + await handleDeviceLogin() }, - } as CommandModule) + }) .command({ command: 'status', describe: 'Show current authentication status', diff --git a/src/commands/main.ts b/src/commands/main.ts index f14baee..c8ff983 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,7 +1,7 @@ import yargs from 'yargs' import { ResultUploadCommandModule } from './resultUpload' import { authCommand } from './auth' -import { qasEnvs, qasEnvFile } from '../utils/env' +import { qasEnvs, qasEnvFile } from '../utils/credentials' import { CLI_VERSION } from '../utils/version' export const run = (args: string | string[]) => diff --git a/src/commands/resultUpload.ts b/src/commands/resultUpload.ts index c93f82e..9b341cf 100644 --- a/src/commands/resultUpload.ts +++ b/src/commands/resultUpload.ts @@ -1,6 +1,6 @@ import { Arguments, Argv, CommandModule } from 'yargs' import chalk from 'chalk' -import { loadEnvs, qasEnvFile } from '../utils/env' +import { resolveAuth } from '../utils/credentials' import { ResultUploadCommandArgs, ResultUploadCommandHandler, @@ -168,11 +168,6 @@ ${chalk.bold('Test Case Matching:')} '--create-tcases' )} to automatically create test cases in QA Sphere. -${chalk.bold('Required environment variables:')} - These should be either defined in a ${qasEnvFile} file or exported as environment variables: - - ${chalk.bold('QAS_TOKEN')}: Your QASphere API token - - ${chalk.bold('QAS_URL')}: Your QASphere instance URL (e.g., https://qas.eu1.qasphere.com) - ${chalk.bold('Run name template placeholders:')} - ${chalk.bold('{env:VAR_NAME}')}: Environment variables - ${chalk.bold('{YYYY}')}: 4-digit year @@ -190,8 +185,8 @@ ${chalk.bold('Run name template placeholders:')} } handler = async (args: Arguments) => { - await loadEnvs() - const handler = new ResultUploadCommandHandler(this.type, args) + const auth = await resolveAuth() + const handler = new ResultUploadCommandHandler(this.type, args, auth) await handler.handle() } } diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts index 555bd33..a98966b 100644 --- a/src/tests/auth-e2e.spec.ts +++ b/src/tests/auth-e2e.spec.ts @@ -8,8 +8,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi const loginServiceUrl = 'https://login.qasphere.com' const tenantUrl = 'https://acme.eu1.qasphere.com' const testApiKey = 'tenantId.keyId.keyToken' -const testApiKeyName = 'My CLI Key' -const testEmail = 'user@example.com' +const testAccessToken = 'tenantId.authId7chars.randomAccessToken' +const testRefreshToken = 'tenantId.authId7chars.randomRefreshToken' // --- MSW handlers --- @@ -23,18 +23,30 @@ const checkTenantHandler = http.get(`${loginServiceUrl}/api/check-tenant`, ({ re }) const deviceCodeHandler = (interval = 0, expiresIn = 900) => - http.post(`${tenantUrl}/api/auth/device/code`, () => { + http.post(`${tenantUrl}/api/oauth/device/code`, () => { return HttpResponse.json({ - userCode: 'ABCD1234', - deviceCode: 'long-random-device-code', - expiresIn, + device_code: 'long-random-device-code', + user_code: 'ABCD1234', + verification_uri: `${tenantUrl}/settings/oauth/device`, + verification_uri_complete: `${tenantUrl}/settings/oauth/device?code=ABCD1234`, + expires_in: expiresIn, interval, }) }) +const tokenSuccessHandler = (expiresIn = 3600) => + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json({ + access_token: testAccessToken, + token_type: 'Bearer', + expires_in: expiresIn, + refresh_token: testRefreshToken, + }) + }) + const projectsHandler = http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { const auth = request.headers.get('Authorization') - if (auth === `ApiKey ${testApiKey}`) { + if (auth === `ApiKey ${testApiKey}` || auth === `Bearer ${testAccessToken}`) { return HttpResponse.json({ data: [], total: 0 }) } return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) @@ -90,11 +102,10 @@ function mockKeyringAvailable(): Map { return store } -function mockPrompts(teamName: string, apiKey = '') { +function mockPrompts(teamName: string) { vi.doMock('../utils/prompt', () => ({ ensureInteractive: () => {}, prompt: async () => teamName, - promptHidden: async () => apiKey, })) } @@ -114,6 +125,24 @@ function credentialsFilePath() { return join(testHomeDir, '.config', 'qasphere', 'credentials.json') } +function writeOAuthCredentials(source: 'file' | 'keyring', store?: Map) { + const creds = { + type: 'oauth', + accessToken: testAccessToken, + refreshToken: testRefreshToken, + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + tenantUrl, + } + if (source === 'keyring' && store) { + store.set('qasphere-cli:credentials', JSON.stringify(creds)) + } else { + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync(join(credDir, 'credentials.json'), JSON.stringify(creds)) + } + return creds +} + // vi.doMock only affects future imports. Since each test sets up different mocks (different prompts, keyring available vs unavailable), // we need resetModules() + dynamic import to get a fresh module tree that picks up the current test's mocks. async function runCommand(args: string) { @@ -137,6 +166,8 @@ beforeEach(() => { return { ...actual, homedir: () => testHomeDir } }) + // Clear credential env vars so resolveCredentialSource() doesn't short-circuit + // to the env_var source, allowing tests to use test-isolated keyring/file/dotenv paths. delete process.env.QAS_TOKEN delete process.env.QAS_URL delete process.env.QAS_LOGIN_SERVICE_URL @@ -169,84 +200,79 @@ afterEach(() => { // --- Tests --- -describe('auth login --api-key lifecycle', () => { - test('login → status → logout → status', async () => { +describe('auth login (device flow)', () => { + test('device flow login succeeds', async () => { mockKeyringUnavailable() - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() - // Use an isolated directory so no .qaspherecli is found after logout - const projectDir = join(testHomeDir, 'project') - mkdirSync(projectDir, { recursive: true }) - const origCwd = process.cwd() - process.chdir(projectDir) + server.use(deviceCodeHandler(), tokenSuccessHandler()) - try { - // Login - await runCommand('auth login --api-key') - expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) - expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + await runCommand('auth login') - // Status (valid) - log.mockClear() - await runCommand('auth status') - expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) - expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) - expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('settings/oauth/device?code=')) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + }) - // Logout - log.mockClear() - await runCommand('auth logout') - expect(log).toHaveBeenCalledWith('Logged out.') + test('device flow saves OAuth credentials', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() - // Status after logout - log.mockClear() - await runCommand('auth status') - expect(log).toHaveBeenCalledWith('Not logged in.') - } finally { - process.chdir(origCwd) - } + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + + const credFile = credentialsFilePath() + expect(existsSync(credFile)).toBe(true) + const parsed = JSON.parse((await import('node:fs')).readFileSync(credFile, 'utf-8')) as Record< + string, + unknown + > + expect(parsed.type).toBe('oauth') + expect(parsed.accessToken).toBe(testAccessToken) + expect(parsed.refreshToken).toBe(testRefreshToken) + expect(parsed.tenantUrl).toBe(tenantUrl) + expect(typeof parsed.accessTokenExpiresAt).toBe('string') }) -}) -describe('auth login (device flow)', () => { - test('device flow login succeeds', async () => { + test('device flow shows timeout on expiry', async () => { mockKeyringUnavailable() mockPrompts('acme') mockBrowser() + const exit = mockProcessExit() + // Use 0 interval and 0 expiresIn so the loop exits immediately server.use( - deviceCodeHandler(), - http.post(`${tenantUrl}/api/auth/device/token`, () => { - return HttpResponse.json({ - status: 'approved', - data: { - key: testApiKey, - keyName: testApiKeyName, - tenantUrl: tenantUrl, - email: testEmail, - }, - }) + deviceCodeHandler(0, 0), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'authorization_pending', error_description: 'user has not yet authorized' }, + { status: 400 } + ) }) ) - await runCommand('auth login') + await runCommand('auth login').catch(() => {}) - expect(log).toHaveBeenCalledWith(expect.stringContaining('ABCD-1234')) - expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) - expect(log).toHaveBeenCalledWith(expect.stringContaining(testApiKeyName)) + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization timed out')) + expect(exit).toHaveBeenCalledWith(1) }) - test('device flow shows timeout on expiry', async () => { + test('device flow handles expired_token from server', async () => { mockKeyringUnavailable() mockPrompts('acme') mockBrowser() const exit = mockProcessExit() - // Use 0 interval and 0 expiresIn so the loop exits immediately server.use( - deviceCodeHandler(0, 0), - http.post(`${tenantUrl}/api/auth/device/token`, () => { - return HttpResponse.json({ status: 'pending' }) + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'expired_token', error_description: 'device code expired or invalid' }, + { status: 400 } + ) }) ) @@ -256,6 +282,60 @@ describe('auth login (device flow)', () => { expect(exit).toHaveBeenCalledWith(1) }) + test('device flow handles access_denied', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() + const exit = mockProcessExit() + + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'access_denied', error_description: 'user denied the request' }, + { status: 400 } + ) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Authorization denied')) + expect(exit).toHaveBeenCalledWith(1) + }) + + test('device flow handles slow_down by increasing interval', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + mockBrowser() + + let pollCount = 0 + server.use( + deviceCodeHandler(0, 900), + http.post(`${tenantUrl}/api/oauth/token`, () => { + pollCount++ + if (pollCount === 1) { + return HttpResponse.json( + { error: 'slow_down', error_description: 'polling too frequently' }, + { status: 400 } + ) + } + // Second poll succeeds + return HttpResponse.json({ + access_token: testAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: testRefreshToken, + }) + }) + ) + + await runCommand('auth login') + + expect(pollCount).toBe(2) + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + }, 10_000) + test('device flow handles device code request failure', async () => { mockKeyringUnavailable() mockPrompts('acme') @@ -263,8 +343,11 @@ describe('auth login (device flow)', () => { const exit = mockProcessExit() server.use( - http.post(`${tenantUrl}/api/auth/device/code`, () => { - return HttpResponse.json({ message: 'Internal server error' }, { status: 500 }) + http.post(`${tenantUrl}/api/oauth/device/code`, () => { + return HttpResponse.json( + { error: 'server_error', error_description: 'Internal server error' }, + { status: 500 } + ) }) ) @@ -278,10 +361,10 @@ describe('auth login (device flow)', () => { describe('auth login error cases', () => { test('check-tenant returns 404 for unknown team', async () => { mockKeyringUnavailable() - mockPrompts('nonexistent', testApiKey) + mockPrompts('nonexistent') const exit = mockProcessExit() - await runCommand('auth login --api-key').catch(() => {}) + await runCommand('auth login').catch(() => {}) expect(err).toHaveBeenCalledWith(expect.stringContaining('Could not find team')) expect(exit).toHaveBeenCalledWith(1) @@ -289,24 +372,59 @@ describe('auth login error cases', () => { test('empty team name shows error', async () => { mockKeyringUnavailable() - mockPrompts('', testApiKey) + mockPrompts('') const exit = mockProcessExit() - await runCommand('auth login --api-key').catch(() => {}) + await runCommand('auth login').catch(() => {}) expect(err).toHaveBeenCalledWith(expect.stringContaining('Team name is required')) expect(exit).toHaveBeenCalledWith(1) }) +}) - test('empty API key shows error', async () => { +describe('auth login → status → logout lifecycle', () => { + test('full lifecycle with device flow', async () => { mockKeyringUnavailable() - mockPrompts('acme', '') - const exit = mockProcessExit() + mockPrompts('acme') + mockBrowser() - await runCommand('auth login --api-key').catch(() => {}) + server.use(deviceCodeHandler(), tokenSuccessHandler()) - expect(err).toHaveBeenCalledWith(expect.stringContaining('API key is required')) - expect(exit).toHaveBeenCalledWith(1) + // Use an isolated directory so no .qaspherecli is found after logout + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + // Login + await runCommand('auth login') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + + // Status (valid) + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Access token expires')) + + // Logout + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(log).toHaveBeenCalledWith( + expect.stringContaining('authorization is still active on the server') + ) + + // Status after logout + log.mockClear() + await runCommand('auth status') + expect(log).toHaveBeenCalledWith('Not logged in.') + } finally { + process.chdir(origCwd) + } }) }) @@ -409,6 +527,21 @@ describe('auth status credential sources', () => { process.chdir(origCwd) } }) + + test.each([ + { source: 'credentials.json' as const, setupKeyring: false }, + { source: 'keyring' as const, setupKeyring: true }, + ])('shows expiry for OAuth credentials from $source', async ({ source, setupKeyring }) => { + const store = setupKeyring ? mockKeyringAvailable() : undefined + if (!setupKeyring) mockKeyringUnavailable() + writeOAuthCredentials(setupKeyring ? 'keyring' : 'file', store) + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining(source)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('Access token expires')) + }) }) describe('auth logout edge cases', () => { @@ -441,7 +574,7 @@ describe('auth logout edge cases', () => { test('second logout after file cleared shows not logged in', async () => { mockKeyringUnavailable() - mockPrompts('acme', testApiKey) + writeOAuthCredentials('file') const projectDir = join(testHomeDir, 'project') mkdirSync(projectDir, { recursive: true }) @@ -449,10 +582,6 @@ describe('auth logout edge cases', () => { process.chdir(projectDir) try { - await runCommand('auth login --api-key') - expect(existsSync(credentialsFilePath())).toBe(true) - - log.mockClear() await runCommand('auth logout') expect(log).toHaveBeenCalledWith('Logged out.') expect(existsSync(credentialsFilePath())).toBe(false) @@ -464,6 +593,80 @@ describe('auth logout edge cases', () => { process.chdir(origCwd) } }) + + test('logout mentions server-side revocation', async () => { + mockKeyringUnavailable() + writeOAuthCredentials('file') + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + const origCwd = process.cwd() + process.chdir(projectDir) + + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith( + expect.stringContaining('authorization is still active on the server') + ) + } finally { + process.chdir(origCwd) + } + }) +}) + +describe('auth logout source labels', () => { + test('cannot log out when using .env file', async () => { + mockKeyringUnavailable() + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync(join(projectDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) + } finally { + process.chdir(origCwd) + } + }) + + test('cannot log out when using .qaspherecli file', async () => { + mockKeyringUnavailable() + + const projectDir = join(testHomeDir, 'project') + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, '.qaspherecli'), + `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + ) + + const origCwd = process.cwd() + process.chdir(projectDir) + try { + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + } finally { + process.chdir(origCwd) + } + }) + + test('logout warns when env vars still active after clearing credentials', async () => { + mockKeyringUnavailable() + writeOAuthCredentials('file') + + process.env.QAS_TOKEN = testApiKey + process.env.QAS_URL = tenantUrl + + log.mockClear() + await runCommand('auth logout') + expect(log).toHaveBeenCalledWith('Logged out.') + expect(log).toHaveBeenCalledWith(expect.stringContaining('still available')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) + }) }) describe('credential storage (keyring setPassword failure)', () => { @@ -482,9 +685,12 @@ describe('credential storage (keyring setPassword failure)', () => { } }, })) - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() + + server.use(deviceCodeHandler(), tokenSuccessHandler()) - await runCommand('auth login --api-key') + await runCommand('auth login') expect(log).toHaveBeenCalledWith(expect.stringContaining('credentials.json')) expect(existsSync(credentialsFilePath())).toBe(true) @@ -494,9 +700,12 @@ describe('credential storage (keyring setPassword failure)', () => { describe('credential storage (keyring unavailable)', () => { test('saves to file with 0600 permissions', async () => { mockKeyringUnavailable() - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() - await runCommand('auth login --api-key') + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') const credFile = credentialsFilePath() expect(existsSync(credFile)).toBe(true) @@ -504,62 +713,73 @@ describe('credential storage (keyring unavailable)', () => { }) test('overwrites existing credentials on re-login', async () => { - const secondApiKey = 'tenantId.keyId2.keyToken2' - - // Accept both API keys for validation - server.use( - http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { - const auth = request.headers.get('Authorization') - if (auth === `ApiKey ${testApiKey}` || auth === `ApiKey ${secondApiKey}`) { - return HttpResponse.json({ data: [], total: 0 }) - } - return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) - }) - ) + const secondAccessToken = 'tenantId.authId7chars.secondAccessToken' + const secondRefreshToken = 'tenantId.authId7chars.secondRefreshToken' mockKeyringUnavailable() - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() - await runCommand('auth login --api-key') + server.use(deviceCodeHandler(), tokenSuccessHandler()) - // Re-login with different valid key - vi.doUnmock('../utils/prompt') - mockPrompts('acme', secondApiKey) + await runCommand('auth login') - log.mockClear() - await runCommand('auth login --api-key') - expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + // Re-login with different tokens + server.use( + deviceCodeHandler(), + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json({ + access_token: secondAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: secondRefreshToken, + }) + }) + ) - // Verify status uses the new key log.mockClear() - await runCommand('auth status') + await runCommand('auth login') expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + + // Verify file has the new token + const parsed = JSON.parse( + (await import('node:fs')).readFileSync(credentialsFilePath(), 'utf-8') + ) as Record + expect(parsed.accessToken).toBe(secondAccessToken) }) }) describe('credential storage (keyring available)', () => { test('saves to keyring when available', async () => { const store = mockKeyringAvailable() - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() + + server.use(deviceCodeHandler(), tokenSuccessHandler()) expect(store.size).toBe(0) - await runCommand('auth login --api-key') + await runCommand('auth login') expect(store.size).toBe(1) const value = Array.from(store.values())[0] - expect(JSON.parse(value)).toEqual({ tenantUrl, apiKey: testApiKey }) + const parsed = JSON.parse(value) as Record + expect(parsed.type).toBe('oauth') + expect(parsed.accessToken).toBe(testAccessToken) + expect(parsed.tenantUrl).toBe(tenantUrl) expect(log).toHaveBeenCalledWith(expect.stringContaining('keyring')) expect(existsSync(credentialsFilePath())).toBe(false) }) test('logout clears keyring entry', async () => { const store = mockKeyringAvailable() - mockPrompts('acme', testApiKey) + mockPrompts('acme') + mockBrowser() + + server.use(deviceCodeHandler(), tokenSuccessHandler()) - await runCommand('auth login --api-key') + await runCommand('auth login') expect(store.size).toBe(1) - const value = Array.from(store.values())[0] - expect(JSON.parse(value)).toEqual({ tenantUrl, apiKey: testApiKey }) + log.mockClear() await runCommand('auth logout') expect(log).toHaveBeenCalledWith('Logged out.') @@ -567,8 +787,11 @@ describe('credential storage (keyring available)', () => { }) test('second logout after keyring cleared shows not logged in', async () => { - mockKeyringAvailable() - mockPrompts('acme', testApiKey) + const store = mockKeyringAvailable() + mockPrompts('acme') + mockBrowser() + + server.use(deviceCodeHandler(), tokenSuccessHandler()) const projectDir = join(testHomeDir, 'project') mkdirSync(projectDir, { recursive: true }) @@ -576,7 +799,8 @@ describe('credential storage (keyring available)', () => { process.chdir(projectDir) try { - await runCommand('auth login --api-key') + await runCommand('auth login') + expect(store.size).toBe(1) log.mockClear() await runCommand('auth logout') @@ -591,10 +815,14 @@ describe('credential storage (keyring available)', () => { }) test('auth status shows keyring as source', async () => { - mockKeyringAvailable() - mockPrompts('acme', testApiKey) + const store = mockKeyringAvailable() + mockPrompts('acme') + mockBrowser() - await runCommand('auth login --api-key') + server.use(deviceCodeHandler(), tokenSuccessHandler()) + + await runCommand('auth login') + expect(store.size).toBe(1) log.mockClear() await runCommand('auth status') @@ -654,10 +882,10 @@ describe('credential resolution edge cases', () => { test('credentials file with wrong shape warns and falls back gracefully', async () => { mockKeyringUnavailable() - // Write valid JSON but wrong shape + // Write valid JSON but wrong shape (legacy apiKey format without type) const credDir = join(testHomeDir, '.config', 'qasphere') mkdirSync(credDir, { recursive: true }) - writeFileSync(join(credDir, 'credentials.json'), '{"foo": "bar"}') + writeFileSync(join(credDir, 'credentials.json'), '{"apiKey": "test", "tenantUrl": "test"}') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -676,60 +904,105 @@ describe('credential resolution edge cases', () => { }) }) -describe('auth logout source labels', () => { - test('cannot log out when using .env file', async () => { +describe('token refresh at load time', () => { + test('refreshes expired access token before running command', async () => { mockKeyringUnavailable() - const projectDir = join(testHomeDir, 'project') - mkdirSync(projectDir, { recursive: true }) - writeFileSync(join(projectDir, '.env'), `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n`) + // Write credentials with expired access token + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) + writeFileSync( + join(credDir, 'credentials.json'), + JSON.stringify({ + type: 'oauth', + accessToken: 'expired-access-token', + refreshToken: testRefreshToken, + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), // expired 1 min ago + tenantUrl, + }) + ) - const origCwd = process.cwd() - process.chdir(projectDir) - try { - await runCommand('auth logout') - expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) - expect(log).toHaveBeenCalledWith(expect.stringContaining('.env')) - } finally { - process.chdir(origCwd) - } + const refreshedAccessToken = 'tenantId.authId7chars.refreshedAccessToken' + const refreshedRefreshToken = 'tenantId.authId7chars.refreshedRefreshToken' + + server.use( + http.post(`${tenantUrl}/api/oauth/token`, async ({ request }) => { + const body = (await request.json()) as Record + if (body.grant_type === 'refresh_token' && body.refresh_token === testRefreshToken) { + return HttpResponse.json({ + access_token: refreshedAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: refreshedRefreshToken, + }) + } + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'invalid refresh token' }, + { status: 401 } + ) + }), + http.get(`${tenantUrl}/api/public/v0/project`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (auth === `Bearer ${refreshedAccessToken}`) { + return HttpResponse.json({ data: [], total: 0 }) + } + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }) + ) + + await runCommand('auth status') + + expect(log).toHaveBeenCalledWith(expect.stringContaining(`Logged in to ${tenantUrl}`)) + expect(log).toHaveBeenCalledWith(expect.stringContaining('valid')) + + // Verify credentials file was updated with new tokens + const parsed = JSON.parse( + (await import('node:fs')).readFileSync(credentialsFilePath(), 'utf-8') + ) as Record + expect(parsed.accessToken).toBe(refreshedAccessToken) + expect(parsed.refreshToken).toBe(refreshedRefreshToken) }) - test('cannot log out when using .qaspherecli file', async () => { + test('expired refresh token shows session expired message', async () => { mockKeyringUnavailable() + const exit = mockProcessExit() - const projectDir = join(testHomeDir, 'project') - mkdirSync(projectDir, { recursive: true }) + // Write credentials with expired access token and a refresh token that will fail + const credDir = join(testHomeDir, '.config', 'qasphere') + mkdirSync(credDir, { recursive: true }) writeFileSync( - join(projectDir, '.qaspherecli'), - `QAS_TOKEN=${testApiKey}\nQAS_URL=${tenantUrl}\n` + join(credDir, 'credentials.json'), + JSON.stringify({ + type: 'oauth', + accessToken: 'expired-access-token', + refreshToken: 'expired-refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), + tenantUrl, + }) ) + server.use( + http.post(`${tenantUrl}/api/oauth/token`, () => { + return HttpResponse.json( + { error: 'invalid_grant', error_description: 'refresh token expired' }, + { status: 401 } + ) + }) + ) + + const emptyDir = join(testHomeDir, 'empty') + mkdirSync(emptyDir, { recursive: true }) const origCwd = process.cwd() - process.chdir(projectDir) + process.chdir(emptyDir) + try { - await runCommand('auth logout') - expect(log).toHaveBeenCalledWith(expect.stringContaining('Cannot log out')) - expect(log).toHaveBeenCalledWith(expect.stringContaining('.qaspherecli')) + await runCommand('auth status').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('Session expired')) + expect(err).toHaveBeenCalledWith(expect.stringContaining('qasphere auth login')) + expect(exit).toHaveBeenCalledWith(1) } finally { process.chdir(origCwd) } }) - - test('logout warns when env vars still active after clearing keyring', async () => { - mockKeyringAvailable() - mockPrompts('acme', testApiKey) - - await runCommand('auth login --api-key') - - // Set env vars that will persist after keyring is cleared - process.env.QAS_TOKEN = testApiKey - process.env.QAS_URL = tenantUrl - - log.mockClear() - await runCommand('auth logout') - expect(log).toHaveBeenCalledWith('Logged out.') - expect(log).toHaveBeenCalledWith(expect.stringContaining('still available')) - expect(log).toHaveBeenCalledWith(expect.stringContaining('environment variables')) - }) }) diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts deleted file mode 100644 index b95c03b..0000000 --- a/src/utils/credentials.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs' -import { join } from 'node:path' -import { homedir } from 'node:os' - -const KEYRING_SERVICE = 'qasphere-cli' -const KEYRING_ACCOUNT = 'credentials' -const CONFIG_DIR = join(homedir(), '.config', 'qasphere') -const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json') - -export interface StoredCredentials { - apiKey: string - tenantUrl: string -} - -export type CredentialSource = 'env_var' | '.env' | 'keyring' | 'credentials.json' | '.qaspherecli' - -interface KeyringEntry { - setPassword(password: string): void - getPassword(): string - deletePassword(): void -} - -type KeyringModule = { - Entry: new (service: string, account: string) => KeyringEntry -} - -async function loadKeyringModule(): Promise { - try { - return (await import('@napi-rs/keyring')) as KeyringModule - } catch { - // Import fails when the native binary is missing (e.g., Alpine/musl where - // the platform-specific @napi-rs/keyring-* package is not installed). - return null - } -} - -async function getKeyringEntry(): Promise { - const mod = await loadKeyringModule() - if (!mod) return null - try { - return new mod.Entry(KEYRING_SERVICE, KEYRING_ACCOUNT) - } catch { - // Entry construction fails when the keyring daemon is unavailable - // (e.g., glibc Linux without D-Bus/Secret Service). - return null - } -} - -export async function saveCredentials(credentials: StoredCredentials): Promise { - const json = JSON.stringify(credentials) - - const entry = await getKeyringEntry() - if (entry) { - try { - entry.setPassword(json) - return 'keyring' - } catch { - // Keyring operation failed, fall through to file - } - } - - // Fallback: write to file with restricted permissions - mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) - writeFileSync(CREDENTIALS_FILE, json, { encoding: 'utf-8', mode: 0o600 }) - chmodSync(CREDENTIALS_FILE, 0o600) // belt-and-suspenders for existing files - return 'credentials.json' -} - -function isValidCredentials(obj: unknown): obj is StoredCredentials { - return ( - typeof obj === 'object' && - obj !== null && - typeof (obj as StoredCredentials).apiKey === 'string' && - typeof (obj as StoredCredentials).tenantUrl === 'string' - ) -} - -export async function loadCredentialsFromKeyring(): Promise { - const entry = await getKeyringEntry() - if (!entry) return null - - try { - const json = entry.getPassword() - const parsed: unknown = JSON.parse(json) - return isValidCredentials(parsed) ? parsed : null - } catch { - // Operations fail when the keyring daemon is unavailable - // (e.g., glibc Linux without D-Bus/Secret Service). - return null - } -} - -export function loadCredentialsFromFile(): StoredCredentials | null { - if (!existsSync(CREDENTIALS_FILE)) return null - - try { - const json = readFileSync(CREDENTIALS_FILE, 'utf-8') - const parsed: unknown = JSON.parse(json) - if (!isValidCredentials(parsed)) { - console.warn(`Warning: credentials file at ${CREDENTIALS_FILE} has invalid format.`) - return null - } - return parsed - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - console.warn(`Warning: could not read credentials file at ${CREDENTIALS_FILE}: ${message}`) - return null - } -} - -export async function clearCredentials(source: CredentialSource): Promise { - if (source === 'keyring') { - const entry = await getKeyringEntry() - if (!entry) throw new Error('Keyring is not available') - entry.deletePassword() - return - } else if (source === 'credentials.json') { - unlinkSync(CREDENTIALS_FILE) - return - } - throw new Error(`Cannot clear credentials from ${source}`) -} diff --git a/src/utils/credentials/index.ts b/src/utils/credentials/index.ts new file mode 100644 index 0000000..40dca53 --- /dev/null +++ b/src/utils/credentials/index.ts @@ -0,0 +1,10 @@ +export type { OAuthCredentials, CredentialSource, AuthConfig } from './types' +export { saveCredentials, clearCredentials } from './storage' +export { + qasEnvFile, + qasEnvs, + resolveCredentialSource, + resolvePersistedCredentialSource, + refreshIfNeeded, + resolveAuth, +} from './resolvers' diff --git a/src/utils/credentials/keyring.ts b/src/utils/credentials/keyring.ts new file mode 100644 index 0000000..ed58eee --- /dev/null +++ b/src/utils/credentials/keyring.ts @@ -0,0 +1,34 @@ +const KEYRING_SERVICE = 'qasphere-cli' +const KEYRING_ACCOUNT = 'credentials' + +export interface KeyringEntry { + setPassword(password: string): void + getPassword(): string + deletePassword(): void +} + +type KeyringModule = { + Entry: new (service: string, account: string) => KeyringEntry +} + +async function loadKeyringModule(): Promise { + try { + return (await import('@napi-rs/keyring')) as KeyringModule + } catch { + // Import fails when the native binary is missing (e.g., Alpine/musl where + // the platform-specific @napi-rs/keyring-* package is not installed). + return null + } +} + +export async function getKeyringEntry(): Promise { + const mod = await loadKeyringModule() + if (!mod) return null + try { + return new mod.Entry(KEYRING_SERVICE, KEYRING_ACCOUNT) + } catch { + // Entry construction fails when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} diff --git a/src/utils/credentials/resolvers.ts b/src/utils/credentials/resolvers.ts new file mode 100644 index 0000000..652d27f --- /dev/null +++ b/src/utils/credentials/resolvers.ts @@ -0,0 +1,191 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { config, DotenvPopulateInput } from 'dotenv' +import chalk from 'chalk' +import { refreshAccessToken } from '../../api/oauth' +import { + saveCredentials, + clearCredentials, + loadCredentialsFromKeyring, + loadCredentialsFromFile, +} from './storage' +import type { + OAuthCredentials, + ApiKeyResolved, + OAuthResolved, + ResolvedCredentials, + AuthConfig, +} from './types' + +export const qasEnvFile = '.qaspherecli' +export const qasEnvs = ['QAS_TOKEN', 'QAS_URL'] + +const REFRESH_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes + +function resolveFromEnvVars(): ApiKeyResolved | null { + if (process.env.QAS_TOKEN && process.env.QAS_URL) { + return { + token: process.env.QAS_TOKEN, + tenantUrl: process.env.QAS_URL, + authType: 'apikey', + source: 'env_var', + } + } + return null +} + +function resolveFromDotenv(): ApiKeyResolved | null { + const dotenvPath = join(process.cwd(), '.env') + if (!existsSync(dotenvPath)) return null + + const fileEnvs: DotenvPopulateInput = {} + config({ path: dotenvPath, processEnv: fileEnvs }) + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + token: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + authType: 'apikey', + source: '.env', + } + } + return null +} + +function resolveFromQaspherecli(): ApiKeyResolved | null { + let dir = process.cwd() + for (;;) { + const envPath = join(dir, qasEnvFile) + if (existsSync(envPath)) { + const fileEnvs: DotenvPopulateInput = {} + config({ path: envPath, processEnv: fileEnvs }) + if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { + return { + token: fileEnvs.QAS_TOKEN, + tenantUrl: fileEnvs.QAS_URL, + authType: 'apikey', + source: '.qaspherecli', + } + } + break + } + + const parentDir = dirname(dir) + if (parentDir === dir) break + dir = parentDir + } + return null +} + +export async function resolvePersistedCredentialSource(): Promise { + const keyringCreds = await loadCredentialsFromKeyring() + if (keyringCreds) { + return { credentials: keyringCreds, authType: 'bearer', source: 'keyring' } + } + + const fileCreds = loadCredentialsFromFile() + if (fileCreds) { + return { credentials: fileCreds, authType: 'bearer', source: 'credentials.json' } + } + return null +} + +/** + * Resolves the credential source without modifying process.env. + * Used by auth status/logout to report where credentials come from. + */ +export async function resolveCredentialSource(): Promise { + // 1. Environment variables + const envResult = resolveFromEnvVars() + if (envResult) return envResult + + // 2. .env file in cwd + const dotenvResult = resolveFromDotenv() + if (dotenvResult) return dotenvResult + + // 3. Keyring or credentials.json (OAuth only) + const persisted = await resolvePersistedCredentialSource() + if (persisted) return persisted + + // 4. .qaspherecli file + return resolveFromQaspherecli() +} + +export async function refreshIfNeeded(resolved: OAuthResolved): Promise { + const expiresAt = new Date(resolved.credentials.accessTokenExpiresAt).getTime() + if (expiresAt - Date.now() >= REFRESH_THRESHOLD_MS) { + return resolved + } + + try { + const tokenResponse = await refreshAccessToken( + resolved.credentials.tenantUrl, + resolved.credentials.refreshToken + ) + + const updated: OAuthCredentials = { + type: 'oauth', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), + tenantUrl: resolved.credentials.tenantUrl, + } + + const newSource = await saveCredentials(updated) + return { credentials: updated, authType: 'bearer', source: newSource } + } catch { + // Refresh failed — clear stale credentials and tell user to re-login + try { + await clearCredentials(resolved.source) + } catch { + // Ignore clear errors + } + + console.error(chalk.red('Session expired.') + ' Please log in again:') + console.error(chalk.green(' qasphere auth login')) + process.exit(1) + } +} + +export async function resolveAuth(): Promise { + const resolved = await resolveCredentialSource() + if (!resolved) { + console.log( + chalk.red('Missing required environment variables: ') + + qasEnvs.filter((k) => !process.env[k]).join(', ') + ) + console.log('\nYou can authenticate using:') + console.log(chalk.green(' qasphere auth login')) + console.log('\nOr create a .qaspherecli file with the following content:') + console.log( + chalk.green(` +QAS_TOKEN=your_token +QAS_URL=http://your-qasphere-instance-url + +# Example: +# QAS_TOKEN=tst0000001.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest +# QAS_URL=http://tenant1.localhost:5173`) + ) + console.log('\nOr export them as environment variables:') + console.log( + chalk.green(` +export QAS_TOKEN=your_token +export QAS_URL=http://your-qasphere-instance-url`) + ) + process.exit(1) + } + + if (resolved.authType === 'bearer') { + const refreshed = await refreshIfNeeded(resolved) + return { + token: refreshed.credentials.accessToken, + baseUrl: refreshed.credentials.tenantUrl, + authType: 'bearer', + } + } + + return { + token: resolved.token, + baseUrl: resolved.tenantUrl, + authType: 'apikey', + } +} diff --git a/src/utils/credentials/storage.ts b/src/utils/credentials/storage.ts new file mode 100644 index 0000000..d6fe1d7 --- /dev/null +++ b/src/utils/credentials/storage.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { getKeyringEntry } from './keyring' +import { oauthCredentialsSchema, type OAuthCredentials, type CredentialSource } from './types' + +const CONFIG_DIR = join(homedir(), '.config', 'qasphere') +const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json') + +export async function saveCredentials(credentials: OAuthCredentials): Promise { + const json = JSON.stringify(credentials) + + const entry = await getKeyringEntry() + if (entry) { + try { + entry.setPassword(json) + return 'keyring' + } catch { + console.warn('Warning: system keyring is not available, saving credentials to file instead.') + } + } + + // Fallback: write to file with restricted permissions + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) + writeFileSync(CREDENTIALS_FILE, json, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(CREDENTIALS_FILE, 0o600) // belt-and-suspenders for existing files + return 'credentials.json' +} + +function parseOAuthCredentials(obj: unknown): OAuthCredentials | null { + const result = oauthCredentialsSchema.safeParse(obj) + return result.success ? result.data : null +} + +export async function loadCredentialsFromKeyring(): Promise { + const entry = await getKeyringEntry() + if (!entry) return null + + try { + const json = entry.getPassword() + return parseOAuthCredentials(JSON.parse(json)) + } catch { + // Operations fail when the keyring daemon is unavailable + // (e.g., glibc Linux without D-Bus/Secret Service). + return null + } +} + +export function loadCredentialsFromFile(): OAuthCredentials | null { + if (!existsSync(CREDENTIALS_FILE)) return null + + try { + const json = readFileSync(CREDENTIALS_FILE, 'utf-8') + const creds = parseOAuthCredentials(JSON.parse(json)) + if (!creds) { + console.warn(`Warning: credentials file at ${CREDENTIALS_FILE} has invalid format.`) + return null + } + return creds + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + console.warn(`Warning: could not read credentials file at ${CREDENTIALS_FILE}: ${message}`) + return null + } +} + +export async function clearCredentials(source: CredentialSource): Promise { + if (source === 'keyring') { + const entry = await getKeyringEntry() + if (!entry) throw new Error('Keyring is not available') + entry.deletePassword() + return + } else if (source === 'credentials.json') { + unlinkSync(CREDENTIALS_FILE) + return + } + throw new Error(`Cannot clear credentials from ${source}`) +} diff --git a/src/utils/credentials/types.ts b/src/utils/credentials/types.ts new file mode 100644 index 0000000..bb5c718 --- /dev/null +++ b/src/utils/credentials/types.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import type { AuthType } from '../../api/utils' + +export const oauthCredentialsSchema = z.object({ + type: z.literal('oauth'), + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + accessTokenExpiresAt: z.string().min(1), // ISO 8601 + tenantUrl: z.string().min(1), +}) + +export type OAuthCredentials = z.infer + +export type CredentialSource = 'env_var' | '.env' | 'keyring' | 'credentials.json' | '.qaspherecli' + +export interface ApiKeyResolved { + token: string + tenantUrl: string + authType: 'apikey' + source: CredentialSource +} + +export interface OAuthResolved { + credentials: OAuthCredentials + authType: 'bearer' + source: CredentialSource +} + +export type ResolvedCredentials = ApiKeyResolved | OAuthResolved + +export interface AuthConfig { + token: string + baseUrl: string + authType: AuthType +} diff --git a/src/utils/env.ts b/src/utils/env.ts deleted file mode 100644 index 4b9f9c8..0000000 --- a/src/utils/env.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { config, DotenvPopulateInput } from 'dotenv' -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import chalk from 'chalk' -import { - loadCredentialsFromKeyring, - loadCredentialsFromFile, - type StoredCredentials, - type CredentialSource, -} from './credentials' - -export const qasEnvFile = '.qaspherecli' -export const qasEnvs = ['QAS_TOKEN', 'QAS_URL'] - -interface ResolvedCredentials { - credentials: StoredCredentials - source: CredentialSource -} - -/** - * Resolves the credential source without modifying process.env. - * Used by auth status/logout to report where credentials come from. - */ -export async function resolveCredentialSource(): Promise { - // 1. Environment variables - if (process.env.QAS_TOKEN && process.env.QAS_URL) { - return { - credentials: { apiKey: process.env.QAS_TOKEN, tenantUrl: process.env.QAS_URL }, - source: 'env_var', - } - } - - // 2. .env file in cwd - const dotenvPath = join(process.cwd(), '.env') - if (existsSync(dotenvPath)) { - const fileEnvs: DotenvPopulateInput = {} - config({ path: dotenvPath, processEnv: fileEnvs }) - if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { - return { - credentials: { - apiKey: fileEnvs.QAS_TOKEN, - tenantUrl: fileEnvs.QAS_URL, - }, - source: '.env', - } - } - } - - // 3. Keyring or credentials.json - const persisted = await resolvePersistedCredentialSource() - if (persisted) return persisted - - // 4. .qaspherecli file - let dir = process.cwd() - for (;;) { - const envPath = join(dir, qasEnvFile) - if (existsSync(envPath)) { - const fileEnvs: DotenvPopulateInput = {} - config({ path: envPath, processEnv: fileEnvs }) - if (fileEnvs.QAS_TOKEN && fileEnvs.QAS_URL) { - return { - credentials: { - apiKey: fileEnvs.QAS_TOKEN, - tenantUrl: fileEnvs.QAS_URL, - }, - source: '.qaspherecli', - } - } - break - } - - const parentDir = dirname(dir) - if (parentDir === dir) break - dir = parentDir - } - - return null -} - -export async function resolvePersistedCredentialSource(): Promise { - const keyringCreds = await loadCredentialsFromKeyring() - if (keyringCreds) { - return { credentials: keyringCreds, source: 'keyring' } - } - - const fileCreds = loadCredentialsFromFile() - if (fileCreds) { - return { credentials: fileCreds, source: 'credentials.json' } - } - return null -} - -export async function loadEnvs(): Promise { - const resolved = await resolveCredentialSource() - if (resolved) { - process.env.QAS_TOKEN = resolved.credentials.apiKey - process.env.QAS_URL = resolved.credentials.tenantUrl - return - } - - console.log( - chalk.red('Missing required environment variables: ') + - qasEnvs.filter((k) => !process.env[k]).join(', ') - ) - console.log('\nYou can authenticate using:') - console.log(chalk.green(' qasphere auth login')) - console.log('\nOr create a .qaspherecli file with the following content:') - console.log( - chalk.green(` -QAS_TOKEN=your_token -QAS_URL=http://your-qasphere-instance-url - -# Example: -# QAS_TOKEN=tst0000001.1CKCEtest_JYyckc3zYtest.dhhjYY3BYEoQH41e62itest -# QAS_URL=http://tenant1.localhost:5173`) - ) - console.log('\nOr export them as environment variables:') - console.log( - chalk.green(` -export QAS_TOKEN=your_token -export QAS_URL=http://your-qasphere-instance-url`) - ) - process.exit(1) -} diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 9a71a86..b1c0bf6 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -23,45 +23,3 @@ export function prompt(question: string): Promise { }) }) } - -export function promptHidden(question: string): Promise { - return new Promise((resolve) => { - process.stdout.write(question) - const chars: string[] = [] - - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.setEncoding('utf-8') - - const onData = (char: string) => { - // Ctrl+C - if (char === '\u0003') { - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - process.exit(0) - } - - // Enter - if (char === '\r' || char === '\n') { - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - resolve(chars.join('')) - return - } - - // Backspace - if (char === '\u007F' || char === '\b') { - chars.pop() - return - } - - chars.push(char) - } - - process.stdin.on('data', onData) - }) -} diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 77cb415..04d6749 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { MarkerParser } from './MarkerParser' import { Api, createApi } from '../../api' +import type { AuthConfig } from '../credentials' import { TCase } from '../../api/schemas' import { ParseResult, TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' @@ -80,12 +81,11 @@ export class ResultUploadCommandHandler { constructor( private type: UploadCommandType, - private args: Arguments + private args: Arguments, + private auth: AuthConfig ) { - const apiToken = process.env.QAS_TOKEN! - - this.baseUrl = process.env.QAS_URL!.replace(/\/+$/, '') - this.api = createApi(this.baseUrl, apiToken) + this.baseUrl = auth.baseUrl + this.api = createApi(this.baseUrl, auth.token, auth.authType) this.markerParser = new MarkerParser(this.type) } @@ -446,7 +446,12 @@ export class ResultUploadCommandHandler { runFailureLogs: string }) { const runUrl = `${this.baseUrl}/project/${projectCode}/run/${runId}` - const uploader = new ResultUploader(this.markerParser, this.type, { ...this.args, runUrl }) + const uploader = new ResultUploader( + this.markerParser, + this.type, + { ...this.args, runUrl }, + this.auth + ) await uploader.handle(results, runFailureLogs) } } diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index 4a17465..e4145b8 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -4,6 +4,7 @@ import escapeHtml from 'escape-html' import { RunTCase } from '../../api/schemas' import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' +import type { AuthConfig } from '../credentials' import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' import type { MarkerParser } from './MarkerParser' @@ -21,14 +22,14 @@ export class ResultUploader { constructor( private markerParser: MarkerParser, private type: UploadCommandType, - private args: Arguments + private args: Arguments, + auth: AuthConfig ) { - const apiToken = process.env.QAS_TOKEN! const { url, project, run } = parseRunUrl(args) this.project = project this.run = run - this.api = createApi(url, apiToken) + this.api = createApi(url, auth.token, auth.authType) } async handle(results: TestCaseResult[], runFailureLogs?: string) { From f35cc5e6df6514903cd34185817c5778912f1897 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Fri, 10 Apr 2026 18:20:13 +0400 Subject: [PATCH 10/10] Check suspended flag --- src/api/oauth.ts | 7 +++++-- src/commands/auth.ts | 6 +++++- src/tests/auth-e2e.spec.ts | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/api/oauth.ts b/src/api/oauth.ts index 1f2be13..efe2e6a 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -16,6 +16,7 @@ const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' export interface CheckTenantResponse { redirectUrl: string + suspended: boolean } export interface OAuthDeviceCodeResponse { @@ -68,7 +69,9 @@ async function oauthErrorResponse(response: Response): Promise { +export async function checkTenant( + teamName: string +): Promise<{ tenantUrl: string; suspended: boolean }> { const fetcher = createFetcher(LOGIN_SERVICE_URL) const response = await fetcher(`/api/check-tenant?name=${encodeURIComponent(teamName)}`, { method: 'GET', @@ -78,7 +81,7 @@ export async function checkTenant(teamName: string): Promise<{ tenantUrl: string // The check-tenant endpoint returns a redirect URL (e.g. http://tenant.localhost:5173/login). // Extract just the origin for use as the API base URL. const origin = new URL(data.redirectUrl).origin - return { tenantUrl: origin } + return { tenantUrl: origin, suspended: data.suspended } } export async function requestDeviceCode(tenantUrl: string): Promise { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 7edde3f..82d215a 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -27,7 +27,11 @@ async function resolveTenantUrl(): Promise { } try { - const { tenantUrl } = await checkTenant(teamName) + const { tenantUrl, suspended } = await checkTenant(teamName) + if (suspended) { + console.error(chalk.red('Error:') + ` Team "${teamName}" has been suspended.`) + process.exit(1) + } return tenantUrl } catch (e) { const message = e instanceof Error ? e.message : String(e) diff --git a/src/tests/auth-e2e.spec.ts b/src/tests/auth-e2e.spec.ts index a98966b..6eb1dfe 100644 --- a/src/tests/auth-e2e.spec.ts +++ b/src/tests/auth-e2e.spec.ts @@ -19,7 +19,7 @@ const checkTenantHandler = http.get(`${loginServiceUrl}/api/check-tenant`, ({ re if (!name || name === 'nonexistent') { return HttpResponse.json({ message: 'Tenant not found' }, { status: 404 }) } - return HttpResponse.json({ redirectUrl: `${tenantUrl}/login` }) + return HttpResponse.json({ redirectUrl: `${tenantUrl}/login`, suspended: false }) }) const deviceCodeHandler = (interval = 0, expiresIn = 900) => @@ -380,6 +380,23 @@ describe('auth login error cases', () => { expect(err).toHaveBeenCalledWith(expect.stringContaining('Team name is required')) expect(exit).toHaveBeenCalledWith(1) }) + + test('suspended team shows error', async () => { + mockKeyringUnavailable() + mockPrompts('acme') + const exit = mockProcessExit() + + server.use( + http.get(`${loginServiceUrl}/api/check-tenant`, () => { + return HttpResponse.json({ redirectUrl: `${tenantUrl}/login`, suspended: true }) + }) + ) + + await runCommand('auth login').catch(() => {}) + + expect(err).toHaveBeenCalledWith(expect.stringContaining('has been suspended')) + expect(exit).toHaveBeenCalledWith(1) + }) }) describe('auth login → status → logout lifecycle', () => {