From d0f4cd215b76df486176bdb7d8e3beb9eb44af31 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 10 Mar 2026 14:28:25 +0100 Subject: [PATCH] feat(cli): add auth commands for OIDC login (#32920) * feat(cli): add auth commands for OIDC login Signed-off-by: benjdlambert * address PR review feedback - move CIMD check before callback server start - add try/finally for callback server cleanup - validate URLs with human-readable errors - deduplicate config URL candidates - preserve selected flag on re-authentication - delete accessToken on logout - log token refresh to stderr in show command - fix command descriptions to reference CIMD not DCR - type keytar as optionalDependency, rename storage paths - add auth-backend changeset Signed-off-by: benjdlambert * migrate auth module from yargs to cleye pattern Signed-off-by: benjdlambert * address PR review feedback - consolidate storage imports in auth.ts - add withMetadataLock to setSelectedInstance - skip file permission tests on Windows - clarify changeset endpoint path Signed-off-by: benjdlambert * address review feedback from Rugvip and Copilot - use stdout for user-facing messages instead of stderr - remove clientSecret remnants from logout - make refresh_token optional in token response schema - add timeout to CIMD metadata fetch - pass same state to callback server and authorize URL - remove inaccurate test comment Signed-off-by: benjdlambert * validate state in callback server, add CIMD endpoint tests - localServer now validates the OAuth state parameter in the request handler and returns 400 on mismatch - Added tests for the CIMD metadata endpoint in OidcRouter covering both disabled and enabled cases Signed-off-by: benjdlambert * revert validateRequest to use Zod error details Signed-off-by: benjdlambert * fix callback server hanging by closing keep-alive connections Signed-off-by: benjdlambert * rename secret store service prefix to backstage-cli:auth-instance Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/auth-backend-cimd-endpoint.md | 5 + .changeset/cli-auth-commands.md | 5 + packages/cli/cli-report.md | 80 ++++ packages/cli/package.json | 5 + packages/cli/src/index.ts | 1 + .../cli/src/modules/auth/commands/list.ts | 33 ++ .../cli/src/modules/auth/commands/login.ts | 383 ++++++++++++++++ .../cli/src/modules/auth/commands/logout.ts | 77 ++++ .../src/modules/auth/commands/printToken.ts | 54 +++ .../cli/src/modules/auth/commands/select.ts | 43 ++ .../cli/src/modules/auth/commands/show.ts | 72 +++ packages/cli/src/modules/auth/index.ts | 53 +++ .../cli/src/modules/auth/lib/auth.test.ts | 336 ++++++++++++++ packages/cli/src/modules/auth/lib/auth.ts | 84 ++++ .../cli/src/modules/auth/lib/http.test.ts | 269 +++++++++++ packages/cli/src/modules/auth/lib/http.ts | 40 ++ .../src/modules/auth/lib/localServer.test.ts | 65 +++ .../cli/src/modules/auth/lib/localServer.ts | 98 ++++ .../cli/src/modules/auth/lib/pkce.test.ts | 178 ++++++++ packages/cli/src/modules/auth/lib/pkce.ts | 36 ++ .../cli/src/modules/auth/lib/prompt.test.ts | 191 ++++++++ packages/cli/src/modules/auth/lib/prompt.ts | 58 +++ .../src/modules/auth/lib/secretStore.test.ts | 250 +++++++++++ .../cli/src/modules/auth/lib/secretStore.ts | 110 +++++ .../cli/src/modules/auth/lib/storage.test.ts | 420 ++++++++++++++++++ packages/cli/src/modules/auth/lib/storage.ts | 177 ++++++++ .../src/service/OidcRouter.test.ts | 118 +++++ .../auth-backend/src/service/OidcRouter.ts | 30 ++ yarn.lock | 44 +- 29 files changed, 3314 insertions(+), 1 deletion(-) create mode 100644 .changeset/auth-backend-cimd-endpoint.md create mode 100644 .changeset/cli-auth-commands.md create mode 100644 packages/cli/src/modules/auth/commands/list.ts create mode 100644 packages/cli/src/modules/auth/commands/login.ts create mode 100644 packages/cli/src/modules/auth/commands/logout.ts create mode 100644 packages/cli/src/modules/auth/commands/printToken.ts create mode 100644 packages/cli/src/modules/auth/commands/select.ts create mode 100644 packages/cli/src/modules/auth/commands/show.ts create mode 100644 packages/cli/src/modules/auth/index.ts create mode 100644 packages/cli/src/modules/auth/lib/auth.test.ts create mode 100644 packages/cli/src/modules/auth/lib/auth.ts create mode 100644 packages/cli/src/modules/auth/lib/http.test.ts create mode 100644 packages/cli/src/modules/auth/lib/http.ts create mode 100644 packages/cli/src/modules/auth/lib/localServer.test.ts create mode 100644 packages/cli/src/modules/auth/lib/localServer.ts create mode 100644 packages/cli/src/modules/auth/lib/pkce.test.ts create mode 100644 packages/cli/src/modules/auth/lib/pkce.ts create mode 100644 packages/cli/src/modules/auth/lib/prompt.test.ts create mode 100644 packages/cli/src/modules/auth/lib/prompt.ts create mode 100644 packages/cli/src/modules/auth/lib/secretStore.test.ts create mode 100644 packages/cli/src/modules/auth/lib/secretStore.ts create mode 100644 packages/cli/src/modules/auth/lib/storage.test.ts create mode 100644 packages/cli/src/modules/auth/lib/storage.ts diff --git a/.changeset/auth-backend-cimd-endpoint.md b/.changeset/auth-backend-cimd-endpoint.md new file mode 100644 index 0000000000..5e2fd6761a --- /dev/null +++ b/.changeset/auth-backend-cimd-endpoint.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added optional client metadata document endpoint at `/.well-known/oauth-client/cli.json` relative to the auth backend base URL for CLI authentication. Enabled when `auth.experimentalClientIdMetadataDocuments.enabled` is set to `true`. diff --git a/.changeset/cli-auth-commands.md b/.changeset/cli-auth-commands.md new file mode 100644 index 0000000000..e642996694 --- /dev/null +++ b/.changeset/cli-auth-commands.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': minor +--- + +Added new `auth` command group for authenticating the CLI with Backstage instances using OAuth 2.0 with a pre-registered client metadata document. Commands include `login`, `logout`, `list`, `show`, `print-token`, and `select` for managing multiple authenticated instances. diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index de2ded54d6..bb22326786 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -12,6 +12,7 @@ Options: -h, --help Commands: + auth [command] build-workspace config [command] config:check @@ -30,6 +31,85 @@ Commands: versions:migrate ``` +### `backstage-cli auth` + +``` +Usage: backstage-cli auth [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + list + login + logout + print-token + select + show +``` + +### `backstage-cli auth list` + +``` +Usage: backstage-cli auth list + +Options: + -h, --help +``` + +### `backstage-cli auth login` + +``` +Usage: backstage-cli auth login + +Options: + --backend-url + --instance + --no-browser + -h, --help +``` + +### `backstage-cli auth logout` + +``` +Usage: backstage-cli auth logout + +Options: + --instance + -h, --help +``` + +### `backstage-cli auth print-token` + +``` +Usage: backstage-cli auth print-token + +Options: + --instance + -h, --help +``` + +### `backstage-cli auth select` + +``` +Usage: backstage-cli auth select + +Options: + --instance + -h, --help +``` + +### `backstage-cli auth show` + +``` +Usage: backstage-cli auth show + +Options: + --instance + -h, --help +``` + ### `backstage-cli build-workspace` ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 9ab5a54a4a..f3eda60401 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -120,6 +120,7 @@ "postcss": "^8.1.0", "postcss-import": "^16.1.0", "process": "^0.11.10", + "proper-lockfile": "^4.1.2", "raw-loader": "^4.0.2", "react-dev-utils": "^12.0.0-next.60", "react-refresh": "^0.17.0", @@ -175,6 +176,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.13.14", "@types/npm-packlist": "^3.0.0", + "@types/proper-lockfile": "^4", "@types/recursive-readdir": "^2.2.0", "@types/rollup-plugin-peer-deps-external": "^2.2.0", "@types/rollup-plugin-postcss": "^3.1.4", @@ -196,6 +198,9 @@ "webpack": "~5.105.0", "webpack-dev-server": "^5.0.0" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "peerDependencies": { "@jest/environment-jsdom-abstract": "^30.0.0", "@module-federation/enhanced": "^0.21.6", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9240ad876c..14da5e791e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,5 +28,6 @@ import { CliInitializer } from './wiring/CliInitializer'; initializer.add(import('./modules/new')); initializer.add(import('./modules/test')); initializer.add(import('./modules/translations')); + initializer.add(import('./modules/auth')); await initializer.run(); })(); diff --git a/packages/cli/src/modules/auth/commands/list.ts b/packages/cli/src/modules/auth/commands/list.ts new file mode 100644 index 0000000000..c245072c88 --- /dev/null +++ b/packages/cli/src/modules/auth/commands/list.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { getAllInstances } from '../lib/storage'; + +export default async ({ args, info }: CommandContext) => { + cli({ help: info }, undefined, args); + + const { instances, selected } = await getAllInstances(); + if (!instances.length) { + process.stderr.write('No instances found\n'); + return; + } + for (const inst of instances) { + const mark = inst.name === selected?.name ? '* ' : ' '; + process.stdout.write(`${mark}${inst.name} - ${inst.baseUrl}\n`); + } +}; diff --git a/packages/cli/src/modules/auth/commands/login.ts b/packages/cli/src/modules/auth/commands/login.ts new file mode 100644 index 0000000000..777fcb1807 --- /dev/null +++ b/packages/cli/src/modules/auth/commands/login.ts @@ -0,0 +1,383 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { startCallbackServer } from '../lib/localServer'; +import { spawn } from 'node:child_process'; +import { challengeFromVerifier, generateVerifier } from '../lib/pkce'; +import { httpJson } from '../lib/http'; +import { + upsertInstance, + withMetadataLock, + getAllInstances, + getInstanceByName, + StoredInstance, +} from '../lib/storage'; +import { getSecretStore } from '../lib/secretStore'; +import crypto from 'node:crypto'; +import fs from 'fs-extra'; +import path from 'node:path'; +import glob from 'glob'; +import YAML from 'yaml'; +import inquirer from 'inquirer'; + +const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { backendUrl, noBrowser, instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + backendUrl: { type: String, description: 'Backend base URL' }, + noBrowser: { + type: Boolean, + description: 'Do not open browser automatically', + }, + instance: { + type: String, + description: 'Name for this instance (used by other auth commands)', + }, + }, + }, + undefined, + args, + ); + + const { instances, selected } = await getAllInstances(); + + let backendBaseUrl: string; + let instanceName: string; + + if (instanceFlag) { + instanceName = instanceFlag; + const targetInstance = instances.find(i => i.name === instanceFlag); + if (targetInstance) { + backendBaseUrl = normalizeUrl(backendUrl) ?? targetInstance.baseUrl; + } else { + backendBaseUrl = normalizeUrl(backendUrl) ?? (await pickBaseUrl()); + } + } else if (backendUrl) { + backendBaseUrl = normalizeUrl(backendUrl); + instanceName = deriveInstanceName(backendBaseUrl); + } else if (instances.length > 0) { + const choice = await promptForInstance(instances, selected); + if (choice === '__new__') { + backendBaseUrl = await pickBaseUrl(); + instanceName = deriveInstanceName(backendBaseUrl); + } else { + const targetInstance = instances.find(i => i.name === choice); + if (!targetInstance) { + throw new Error('Instance not found'); + } + backendBaseUrl = targetInstance.baseUrl; + instanceName = targetInstance.name; + } + } else { + backendBaseUrl = await pickBaseUrl(); + instanceName = deriveInstanceName(backendBaseUrl); + } + + const authBaseUrl = `${backendBaseUrl}/api/auth`; + const clientId = `${authBaseUrl}/.well-known/oauth-client/cli.json`; + + const metadataResponse = await fetch(clientId, { + signal: AbortSignal.timeout(30_000), + }); + if (!metadataResponse.ok) { + throw new Error( + `Server does not support CLI authentication. Ensure CIMD is enabled on the backend.`, + ); + } + + const { verifier, challenge, state } = createPkceState(); + const callback = await startCallbackServer({ state }); + + try { + const authorizeUrl = buildAuthorizeUrl({ + authBaseUrl, + clientId, + redirectUri: callback.url, + state, + challenge, + }); + + await openBrowserOrPrint(authorizeUrl, noBrowser); + + const code = await waitForAuthorizationCode(callback, state); + + const token = await exchangeAuthorizationCode({ + authBaseUrl, + code, + redirectUri: callback.url, + verifier, + }); + + await persistInstance({ + instanceName, + backendBaseUrl, + clientId, + token, + }); + + process.stdout.write('Login successful\n'); + } finally { + await callback.close(); + } +}; + +async function promptForInstance( + instances: StoredInstance[], + selected: StoredInstance | undefined, +): Promise { + const choices = instances.map(i => ({ + name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`, + value: i.name, + })); + + choices.push({ + name: 'Add new instance...', + value: '__new__', + }); + + const { choice } = await inquirer.prompt<{ choice: string }>([ + { + type: 'list', + name: 'choice', + message: 'Select instance to authenticate:', + choices, + default: selected?.name ?? '__new__', + }, + ]); + + return choice; +} + +async function pickBaseUrl() { + const cwd = process.cwd(); + const candidates: Array<{ url: string; file: string }> = []; + + const patterns = [ + 'app-config.yaml', + 'app-config.*.yaml', + 'packages/*/app-config.yaml', + 'packages/*/app-config.*.yaml', + ]; + const files = patterns.flatMap(p => glob.sync(p, { cwd, nodir: true })); + for (const file of files) { + try { + const content = await fs.readFile(path.resolve(cwd, file), 'utf8'); + const doc = YAML.parse(content); + const url = doc?.backend?.baseUrl as string | undefined; + if (url) { + candidates.push({ url: normalizeUrl(url), file }); + } + } catch { + // ignore parse errors + } + } + + const list = [...new Map(candidates.map(c => [c.url, c])).values()]; + if (list.length === 0) { + const { manual } = await inquirer.prompt<{ manual: string }>([ + { type: 'input', name: 'manual', message: 'Enter backend base URL' }, + ]); + return normalizeUrl(manual); + } + if (list.length === 1) { + return list[0].url; + } + + const { picked } = await inquirer.prompt<{ picked: string }>([ + { + type: 'list', + name: 'picked', + message: 'Select backend base URL', + choices: [ + ...list.map(e => ({ name: `${e.url} (${e.file})`, value: e.url })), + { name: 'Enter manually', value: '__manual__' }, + ], + }, + ]); + if (picked === '__manual__') { + const { manual } = await inquirer.prompt<{ manual: string }>([ + { type: 'input', name: 'manual', message: 'Enter backend base URL' }, + ]); + return normalizeUrl(manual); + } + return picked; +} + +function normalizeUrl(u: string): string; +function normalizeUrl(u: string | undefined): string | undefined; +function normalizeUrl(u: string | undefined): string | undefined { + if (u === undefined) { + return undefined; + } + try { + const url = new URL(u); + return url.toString().replace(/\/$/, ''); + } catch { + throw new Error(`'${u}' is not a valid URL`); + } +} + +function deriveInstanceName(url: string): string { + return new URL(url).host; +} + +function createPkceState() { + const verifier = generateVerifier(); + const challenge = challengeFromVerifier(verifier); + const state = cryptoRandom(); + return { verifier, challenge, state }; +} + +function buildAuthorizeUrl(options: { + authBaseUrl: string; + clientId: string; + redirectUri: string; + state: string; + challenge: string; +}): string { + const { authBaseUrl, clientId, redirectUri, state, challenge } = options; + const authorize = new URL(`${authBaseUrl}/v1/authorize`); + authorize.searchParams.set('client_id', clientId); + authorize.searchParams.set('redirect_uri', redirectUri); + authorize.searchParams.set('response_type', 'code'); + authorize.searchParams.set('scope', 'openid offline_access'); + authorize.searchParams.set('state', state); + authorize.searchParams.set('code_challenge', challenge); + authorize.searchParams.set('code_challenge_method', 'S256'); + return authorize.toString(); +} + +async function openBrowserOrPrint(url: string, noBrowser?: boolean) { + if (noBrowser) { + process.stdout.write(`Open this URL to continue: ${url}\n`); + } else { + process.stdout.write(`Opening the following URL: ${url}\n`); + openInBrowser(url); + } +} + +async function waitForAuthorizationCode( + callback: Awaited>, + expectedState: string, +) { + const { code, state } = await callback.waitForCode(); + if (state !== expectedState) { + throw new Error('State mismatch'); + } + return code; +} + +async function exchangeAuthorizationCode(options: { + authBaseUrl: string; + code: string; + redirectUri: string; + verifier: string; +}) { + const { authBaseUrl, code, redirectUri, verifier } = options; + return await httpJson<{ + access_token: string; + token_type: string; + expires_in: number; + id_token?: string; + refresh_token?: string; + }>(`${authBaseUrl}/v1/token`, { + method: 'POST', + body: { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }, + signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS), + }); +} + +async function persistInstance(options: { + instanceName: string; + backendBaseUrl: string; + clientId: string; + token: { access_token: string; refresh_token?: string; expires_in: number }; +}) { + const { instanceName, backendBaseUrl, clientId, token } = options; + const secretStore = await getSecretStore(); + await withMetadataLock(async () => { + const service = `backstage-cli:auth-instance:${instanceName}`; + await secretStore.set(service, 'accessToken', token.access_token); + if (token.refresh_token) { + await secretStore.set(service, 'refreshToken', token.refresh_token); + } else { + process.stderr.write( + 'Warning: No refresh token received. You will need to re-authenticate when the access token expires.\n', + ); + } + let existing: StoredInstance | undefined; + try { + existing = await getInstanceByName(instanceName); + } catch { + // new instance + } + await upsertInstance({ + name: instanceName, + baseUrl: backendBaseUrl, + clientId, + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + token.expires_in * 1000, + selected: existing?.selected, + }); + }); +} + +function cryptoRandom(): string { + return crypto.randomBytes(32).toString('hex'); +} + +// The react-dev-utils/openBrowser breaks the login URL by encoding the URL parameters again +export function openInBrowser(url: string): void { + const handleError = (error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + process.stderr.write( + `Warning: Failed to open browser automatically: ${message}\n`, + ); + process.stderr.write(`Please open this URL manually: ${url}\n`); + }; + + const spawnOpts = { detached: true, stdio: 'ignore' } as const; + let child; + try { + if (process.platform === 'darwin') { + child = spawn('open', [url], spawnOpts); + } else if (process.platform === 'win32') { + child = spawn( + 'powershell', + ['-Command', `Start-Process '${url.replace(/'/g, "''")}'`], + spawnOpts, + ); + } else { + child = spawn('xdg-open', [url], spawnOpts); + } + child.unref(); + child.on('error', handleError); + } catch (error) { + handleError(error); + } +} diff --git a/packages/cli/src/modules/auth/commands/logout.ts b/packages/cli/src/modules/auth/commands/logout.ts new file mode 100644 index 0000000000..8277ea6335 --- /dev/null +++ b/packages/cli/src/modules/auth/commands/logout.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { getSecretStore } from '../lib/secretStore'; +import { + removeInstance, + withMetadataLock, + getInstanceByName, +} from '../lib/storage'; +import { httpJson } from '../lib/http'; +import { pickInstance } from '../lib/prompt'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to log out', + }, + }, + }, + undefined, + args, + ); + + const { name: instanceName } = await pickInstance(instanceFlag); + + await withMetadataLock(async () => { + const instance = await getInstanceByName(instanceName); + const secretStore = await getSecretStore(); + const service = `backstage-cli:auth-instance:${instanceName}`; + const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? ''; + + if (refreshToken) { + try { + const authBaseUrl = new URL('/api/auth', instance.baseUrl) + .toString() + .replace(/\/$/, ''); + await httpJson(`${authBaseUrl}/v1/revoke`, { + method: 'POST', + body: { + token: refreshToken, + token_type_hint: 'refresh_token', + }, + signal: AbortSignal.timeout(30_000), + }); + } catch { + // ignore errors per RFC 7009 + } + } + + await secretStore.delete(service, 'accessToken'); + await secretStore.delete(service, 'refreshToken'); + await removeInstance(instance.name); + }); + + process.stdout.write('Logged out\n'); +}; diff --git a/packages/cli/src/modules/auth/commands/printToken.ts b/packages/cli/src/modules/auth/commands/printToken.ts new file mode 100644 index 0000000000..ea848a112a --- /dev/null +++ b/packages/cli/src/modules/auth/commands/printToken.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; +import { getSelectedInstance } from '../lib/storage'; +import { getSecretStore } from '../lib/secretStore'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + }, + }, + undefined, + args, + ); + + let instance = await getSelectedInstance(instanceFlag); + + if (accessTokenNeedsRefresh(instance)) { + instance = await refreshAccessToken(instance.name); + } + + const secretStore = await getSecretStore(); + const service = `backstage-cli:auth-instance:${instance.name}`; + const accessToken = await secretStore.get(service, 'accessToken'); + if (!accessToken) { + throw new Error('No access token found. Run "auth login" to authenticate.'); + } + + process.stdout.write(`${accessToken}\n`); +}; diff --git a/packages/cli/src/modules/auth/commands/select.ts b/packages/cli/src/modules/auth/commands/select.ts new file mode 100644 index 0000000000..839fee691e --- /dev/null +++ b/packages/cli/src/modules/auth/commands/select.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { setSelectedInstance } from '../lib/storage'; +import { pickInstance } from '../lib/prompt'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to select', + }, + }, + }, + undefined, + args, + ); + + const instance = await pickInstance(instanceFlag); + + await setSelectedInstance(instance.name); + process.stderr.write(`Selected instance '${instance.name}'\n`); +}; diff --git a/packages/cli/src/modules/auth/commands/show.ts b/packages/cli/src/modules/auth/commands/show.ts new file mode 100644 index 0000000000..cd55430f91 --- /dev/null +++ b/packages/cli/src/modules/auth/commands/show.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cli } from 'cleye'; +import type { CommandContext } from '../../../wiring/types'; +import { httpJson } from '../lib/http'; +import { getSelectedInstance } from '../lib/storage'; +import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; +import { getSecretStore } from '../lib/secretStore'; + +export default async ({ args, info }: CommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to show', + }, + }, + }, + undefined, + args, + ); + + let instance = await getSelectedInstance(instanceFlag); + + if (accessTokenNeedsRefresh(instance)) { + process.stdout.write('Refreshing access token...\n'); + instance = await refreshAccessToken(instance.name); + } + const authBase = new URL('/api/auth', instance.baseUrl) + .toString() + .replace(/\/$/, ''); + + const secretStore = await getSecretStore(); + const service = `backstage-cli:auth-instance:${instance.name}`; + const accessToken = await secretStore.get(service, 'accessToken'); + if (!accessToken) { + throw new Error('No access token found. Run "auth login" to authenticate.'); + } + + const userinfo = await httpJson<{ claims: { sub: string; ent: string[] } }>( + `${authBase}/v1/userinfo`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(30_000), + }, + ); + + process.stdout.write(`User: ${userinfo.claims.sub}\n`); + process.stdout.write(`\n`); + process.stdout.write(`Ownership:\n`); + for (const ent of userinfo.claims.ent ?? []) { + process.stdout.write(` - ${ent}\n`); + } +}; diff --git a/packages/cli/src/modules/auth/index.ts b/packages/cli/src/modules/auth/index.ts new file mode 100644 index 0000000000..d5766219b2 --- /dev/null +++ b/packages/cli/src/modules/auth/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createCliPlugin } from '../../wiring/factory'; + +export default createCliPlugin({ + pluginId: 'auth', + init: async reg => { + reg.addCommand({ + path: ['auth', 'login'], + description: 'Log in the CLI to a Backstage instance', + execute: { loader: () => import('./commands/login') }, + }); + reg.addCommand({ + path: ['auth', 'logout'], + description: 'Log out the CLI and clear stored credentials', + execute: { loader: () => import('./commands/logout') }, + }); + reg.addCommand({ + path: ['auth', 'show'], + description: 'Show details of an authenticated instance', + execute: { loader: () => import('./commands/show') }, + }); + reg.addCommand({ + path: ['auth', 'list'], + description: 'List authenticated instances', + execute: { loader: () => import('./commands/list') }, + }); + reg.addCommand({ + path: ['auth', 'print-token'], + description: 'Print an access token to stdout (auto-refresh if needed)', + execute: { loader: () => import('./commands/printToken') }, + }); + reg.addCommand({ + path: ['auth', 'select'], + description: 'Select the default instance', + execute: { loader: () => import('./commands/select') }, + }); + }, +}); diff --git a/packages/cli/src/modules/auth/lib/auth.test.ts b/packages/cli/src/modules/auth/lib/auth.test.ts new file mode 100644 index 0000000000..5ba4ae0295 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/auth.test.ts @@ -0,0 +1,336 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { accessTokenNeedsRefresh, refreshAccessToken } from './auth'; +import * as storage from './storage'; +import * as secretStore from './secretStore'; +import * as http from './http'; + +jest.mock('./storage'); +jest.mock('./secretStore'); +jest.mock('./http'); + +const mockStorage = storage as jest.Mocked; +const mockSecretStore = secretStore as jest.Mocked; +const mockHttp = http as jest.Mocked; + +describe('auth', () => { + describe('accessTokenNeedsRefresh', () => { + it('should return true if token expires within 2 minutes', () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client', + issuedAt: now, + + accessTokenExpiresAt: now + 60_000, // 1 minute from now + }; + + expect(accessTokenNeedsRefresh(instance)).toBe(true); + }); + + it('should return true if token has already expired', () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, // expired 1 minute ago + }; + + expect(accessTokenNeedsRefresh(instance)).toBe(true); + }); + + it('should return false if token is valid for more than 2 minutes', () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client', + issuedAt: now, + + accessTokenExpiresAt: now + 5 * 60_000, // 5 minutes from now + }; + + expect(accessTokenNeedsRefresh(instance)).toBe(false); + }); + + it('should return true at exactly 2 minutes before expiration', () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client', + issuedAt: now, + + accessTokenExpiresAt: now + 2 * 60_000, // exactly 2 minutes from now + }; + + expect(accessTokenNeedsRefresh(instance)).toBe(true); + }); + }); + + describe('refreshAccessToken', () => { + const mockSecretStoreInstance = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSecretStore.getSecretStore.mockResolvedValue(mockSecretStoreInstance); + }); + + it('should successfully refresh access token', async () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client-id', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, + }; + + mockStorage.withMetadataLock.mockImplementation( + async (fn: () => Promise) => fn(), + ); + mockStorage.getInstanceByName.mockResolvedValue(instance); + mockSecretStoreInstance.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'clientSecret') return 'test-secret'; + if (account === 'refreshToken') return 'old-refresh-token'; + return undefined; + }, + ); + + const tokenResponse = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }; + + mockHttp.httpJson.mockResolvedValue(tokenResponse); + mockStorage.upsertInstance.mockResolvedValue(); + + const result = await refreshAccessToken('test'); + + expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('test'); + expect(mockSecretStoreInstance.get).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:test', + 'refreshToken', + ); + expect(mockHttp.httpJson).toHaveBeenCalledWith( + 'http://localhost:7007/api/auth/v1/token', + { + signal: expect.any(AbortSignal), + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token: 'old-refresh-token', + }, + }, + ); + expect(mockSecretStoreInstance.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:test', + 'accessToken', + 'new-access-token', + ); + expect(mockSecretStoreInstance.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:test', + 'refreshToken', + 'new-refresh-token', + ); + expect(mockStorage.upsertInstance).toHaveBeenCalled(); + expect(result.accessTokenExpiresAt).toBeGreaterThan(now); + }); + + it('should throw error if refresh token is missing', async () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client-id', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, + }; + + mockStorage.withMetadataLock.mockImplementation( + async (fn: () => Promise) => fn(), + ); + mockStorage.getInstanceByName.mockResolvedValue(instance); + mockSecretStoreInstance.get.mockResolvedValue(undefined); + + await expect(refreshAccessToken('test')).rejects.toThrow( + 'Access token is expired and no refresh token is available', + ); + }); + + it('should use metadata lock during refresh', async () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client-id', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, + }; + + let lockAcquired = false; + mockStorage.withMetadataLock.mockImplementation( + async (fn: () => Promise) => { + lockAcquired = true; + return fn(); + }, + ); + mockStorage.getInstanceByName.mockResolvedValue(instance); + mockSecretStoreInstance.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'clientSecret') return 'test-secret'; + if (account === 'refreshToken') return 'refresh-token'; + return undefined; + }, + ); + + const tokenResponse = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }; + + mockHttp.httpJson.mockResolvedValue(tokenResponse); + mockStorage.upsertInstance.mockResolvedValue(); + + await refreshAccessToken('test'); + + expect(lockAcquired).toBe(true); + expect(mockStorage.withMetadataLock).toHaveBeenCalled(); + }); + + it('should handle HTTP and network errors during refresh', async () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client-id', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, + }; + + const errorCases = [ + new Error('Request failed with 401 Unauthorized'), + new Error('Network error'), + ]; + + for (const error of errorCases) { + mockStorage.withMetadataLock.mockImplementation( + async (fn: () => Promise) => fn(), + ); + mockStorage.getInstanceByName.mockResolvedValue(instance); + mockSecretStoreInstance.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'clientSecret') return 'test-secret'; + if (account === 'refreshToken') return 'refresh-token'; + return undefined; + }, + ); + + mockHttp.httpJson.mockRejectedValue(error); + + await expect(refreshAccessToken('test')).rejects.toThrow(error.message); + } + }); + + it('should validate token response and reject malformed responses', async () => { + const now = Date.now(); + const instance = { + name: 'test', + baseUrl: 'http://localhost:7007', + clientId: 'test-client-id', + issuedAt: now - 3600_000, + + accessTokenExpiresAt: now - 60_000, + }; + + mockStorage.withMetadataLock.mockImplementation( + async (fn: () => Promise) => fn(), + ); + mockStorage.getInstanceByName.mockResolvedValue(instance); + mockSecretStoreInstance.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'clientSecret') return 'test-secret'; + if (account === 'refreshToken') return 'refresh-token'; + return undefined; + }, + ); + + // Test missing access_token + mockHttp.httpJson.mockResolvedValue({ + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + } as any); + + await expect(refreshAccessToken('test')).rejects.toThrow( + 'Invalid token response', + ); + await expect(refreshAccessToken('test')).rejects.toThrow('access_token'); + + // Test missing expires_in + mockHttp.httpJson.mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token', + } as any); + + await expect(refreshAccessToken('test')).rejects.toThrow( + 'Invalid token response', + ); + await expect(refreshAccessToken('test')).rejects.toThrow('expires_in'); + + // Test missing refresh_token still succeeds and preserves existing token + mockHttp.httpJson.mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + } as any); + + await expect(refreshAccessToken('test')).resolves.toBeDefined(); + + // Test invalid expires_in (non-positive) + mockHttp.httpJson.mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 0, + refresh_token: 'new-refresh-token', + } as any); + + await expect(refreshAccessToken('test')).rejects.toThrow( + 'Invalid token response', + ); + await expect(refreshAccessToken('test')).rejects.toThrow('expires_in'); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/auth.ts b/packages/cli/src/modules/auth/lib/auth.ts new file mode 100644 index 0000000000..3ea7baef5c --- /dev/null +++ b/packages/cli/src/modules/auth/lib/auth.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { + StoredInstance, + upsertInstance, + withMetadataLock, + getInstanceByName, +} from './storage'; +import { getSecretStore } from './secretStore'; +import { httpJson } from './http'; + +const TokenResponseSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.number().positive().finite(), + refresh_token: z.string().min(1).optional(), +}); + +export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { + return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration +} + +export async function refreshAccessToken( + instanceName: string, +): Promise { + const secretStore = await getSecretStore(); + + return withMetadataLock(async () => { + const instance = await getInstanceByName(instanceName); + + const service = `backstage-cli:auth-instance:${instanceName}`; + const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? ''; + if (!refreshToken) { + throw new Error( + 'Access token is expired and no refresh token is available', + ); + } + + const response = await httpJson( + `${instance.baseUrl}/api/auth/v1/token`, + { + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, + signal: AbortSignal.timeout(30_000), + }, + ); + + const parsed = TokenResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error(`Invalid token response: ${parsed.error.message}`); + } + const token = parsed.data; + + await secretStore.set(service, 'accessToken', token.access_token); + if (token.refresh_token) { + await secretStore.set(service, 'refreshToken', token.refresh_token); + } + const newInstance = { + ...instance, + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + token.expires_in * 1000, + }; + await upsertInstance(newInstance); + return newInstance; + }); +} diff --git a/packages/cli/src/modules/auth/lib/http.test.ts b/packages/cli/src/modules/auth/lib/http.test.ts new file mode 100644 index 0000000000..74416a0dee --- /dev/null +++ b/packages/cli/src/modules/auth/lib/http.test.ts @@ -0,0 +1,269 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import { httpJson } from './http'; + +jest.mock('cross-fetch'); + +const mockFetch = fetch as jest.MockedFunction; + +describe('http', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('httpJson', () => { + it('should make successful GET request and parse JSON', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ data: 'test' }), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + const result = await httpJson('https://example.com/api'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + body: undefined, + }), + ); + expect(result).toEqual({ data: 'test' }); + }); + + it('should make POST request with JSON body', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ success: true }), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + const body = { username: 'test', password: 'secret' }; + const result = await httpJson('https://example.com/api', { + method: 'POST', + body, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('should include and merge custom headers', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ data: 'test' }), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + // Test custom headers without body + await httpJson('https://example.com/api', { + headers: { + Authorization: 'Bearer token', + 'X-Custom': 'value', + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + headers: { + Authorization: 'Bearer token', + 'X-Custom': 'value', + }, + }), + ); + + // Test merging headers with content-type when body is present + await httpJson('https://example.com/api', { + method: 'POST', + body: { data: 'test' }, + headers: { + Authorization: 'Bearer token', + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }, + }), + ); + }); + + it('should throw ResponseError for non-ok responses', async () => { + const errorCases = [ + { status: 404, statusText: 'Not Found' }, + { status: 401, statusText: 'Unauthorized' }, + { status: 500, statusText: 'Internal Server Error' }, + ]; + + for (const { status, statusText } of errorCases) { + const mockResponse = { + ok: false, + status, + statusText, + url: 'https://example.com/api', + text: jest.fn().mockResolvedValue('Error'), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + await expect(httpJson('https://example.com/api')).rejects.toThrow( + `Request failed with ${status} ${statusText}`, + ); + } + }); + + it('should pass through abort signal from caller', async () => { + const abortController = new AbortController(); + let rejectFn: (error: Error) => void; + const mockResponse = new Promise((_, reject) => { + rejectFn = reject; + setTimeout(() => { + reject(new Error('Request should have been aborted')); + }, 60000); // 60 seconds + }); + + mockFetch.mockImplementation((_url, options) => { + const signal = options?.signal as AbortSignal; + signal?.addEventListener('abort', () => { + rejectFn(new Error('The operation was aborted')); + }); + return mockResponse as any; + }); + + const requestPromise = httpJson('https://example.com/api', { + signal: abortController.signal, + }); + + // Abort the request + abortController.abort(); + + await expect(requestPromise).rejects.toThrow('The operation was aborted'); + }); + + it('should handle JSON parsing errors gracefully', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + await expect(httpJson('https://example.com/api')).rejects.toThrow( + 'Invalid JSON', + ); + }); + + it('should support different HTTP methods', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ success: true }), + }; + + for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) { + mockFetch.mockResolvedValue(mockResponse as any); + + await httpJson('https://example.com/api', { method }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + method, + }), + ); + } + }); + + it('should handle various response body types', async () => { + const testCases = [ + { body: null, expected: null }, + { body: [1, 2, 3], expected: [1, 2, 3] }, + { body: { data: 'test' }, expected: { data: 'test' } }, + ]; + + for (const { body, expected } of testCases) { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(body), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + const result = await httpJson('https://example.com/api'); + expect(result).toEqual(expected); + } + }); + + it('should handle network errors', async () => { + const networkError = new Error('Network error'); + mockFetch.mockRejectedValue(networkError); + + await expect(httpJson('https://example.com/api')).rejects.toThrow( + 'Network error', + ); + }); + + it('should use custom abort signal if provided', async () => { + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ data: 'test' }), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + const customController = new AbortController(); + await httpJson('https://example.com/api', { + signal: customController.signal, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + it('should handle malformed URLs gracefully', async () => { + const networkError = new TypeError('Failed to parse URL'); + mockFetch.mockRejectedValue(networkError); + + await expect(httpJson('not-a-valid-url')).rejects.toThrow(); + }); + + it('should handle very large response bodies', async () => { + const largeData = { items: Array(10000).fill({ data: 'x'.repeat(100) }) }; + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(largeData), + }; + mockFetch.mockResolvedValue(mockResponse as any); + + const result = await httpJson('https://example.com/api'); + expect(result).toEqual(largeData); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/http.ts b/packages/cli/src/modules/auth/lib/http.ts new file mode 100644 index 0000000000..307d7d2334 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/http.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import { ResponseError } from '@backstage/errors'; + +type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +export async function httpJson(url: string, init?: HttpInit): Promise { + const res = await fetch(url, { + ...init, + body: init?.body ? JSON.stringify(init.body) : undefined, + headers: { + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, + }); + if (!res.ok) { + throw await ResponseError.fromResponse(res); + } + return (await res.json()) as T; +} diff --git a/packages/cli/src/modules/auth/lib/localServer.test.ts b/packages/cli/src/modules/auth/lib/localServer.test.ts new file mode 100644 index 0000000000..3b03331d3e --- /dev/null +++ b/packages/cli/src/modules/auth/lib/localServer.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { startCallbackServer } from './localServer'; + +describe('localServer', () => { + it('should start on port 8055, handle requests, and resolve the code', async () => { + const { url, waitForCode, close } = await startCallbackServer({ + state: 'test-state', + }); + + expect(url).toBe('http://127.0.0.1:8055/callback'); + + // 404 for non-callback paths + const notFoundResponse = await fetch( + url.replace('/callback', '/other-path'), + ); + expect(notFoundResponse.status).toBe(404); + + // 400 for missing code + const missingCodeResponse = await fetch(`${url}?state=test-state`); + expect(missingCodeResponse.status).toBe(400); + expect(await missingCodeResponse.text()).toBe('Missing code'); + + // 400 for mismatched state + const mismatchResponse = await fetch( + `${url}?code=test-code&state=wrong-state`, + ); + expect(mismatchResponse.status).toBe(400); + expect(await mismatchResponse.text()).toBe('State mismatch'); + + // 200 for valid callback with matching state + const codePromise = waitForCode(); + const specialCode = 'test-code+with/special=chars'; + const successResponse = await fetch( + `${url}?code=${encodeURIComponent( + specialCode, + )}&state=${encodeURIComponent('test-state')}`, + ); + expect(successResponse.status).toBe(200); + expect(await successResponse.text()).toBe('You may now close this window.'); + expect(successResponse.headers.get('content-type')).toBe( + 'text/plain; charset=utf-8', + ); + + const result = await codePromise; + expect(result.code).toBe(specialCode); + expect(result.state).toBe('test-state'); + + await close(); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/localServer.ts b/packages/cli/src/modules/auth/lib/localServer.ts new file mode 100644 index 0000000000..0b71075a5b --- /dev/null +++ b/packages/cli/src/modules/auth/lib/localServer.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import http from 'node:http'; +import { URL } from 'node:url'; + +const CALLBACK_PORT = 8055; + +export async function startCallbackServer(options: { state: string }): Promise<{ + url: string; + waitForCode: () => Promise<{ code: string; state?: string }>; + close: () => Promise; +}> { + const server = http.createServer(); + + let resolveResult: + | ((v: { code: string; state?: string }) => void) + | undefined; + const resultPromise = new Promise<{ code: string; state?: string }>( + resolve => { + resolveResult = resolve; + }, + ); + + server.on('request', (req, res) => { + if (!req.url) { + res.statusCode = 400; + res.end('Bad Request'); + return; + } + const u = new URL(req.url, 'http://127.0.0.1'); + if (u.pathname !== '/callback') { + res.statusCode = 404; + res.end('Not Found'); + return; + } + const code = u.searchParams.get('code') ?? undefined; + const state = u.searchParams.get('state') ?? undefined; + if (!code) { + res.statusCode = 400; + res.end('Missing code'); + return; + } + if (state !== options.state) { + res.statusCode = 400; + res.end('State mismatch'); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('You may now close this window.'); + resolveResult?.({ code, state }); + }); + + const port = await new Promise((resolve, reject) => { + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject( + new Error( + `Port ${CALLBACK_PORT} is already in use. Close the application using it and try again.`, + ), + ); + } else { + reject(err); + } + }); + server.listen(CALLBACK_PORT, '127.0.0.1', () => { + const address = server.address(); + if (typeof address === 'object' && address && 'port' in address) { + resolve(address.port); + } else { + reject(new Error('Failed to bind local server')); + } + }); + }); + + return { + url: `http://127.0.0.1:${port}/callback`, + waitForCode: () => resultPromise, + close: async () => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); + }, + }; +} diff --git a/packages/cli/src/modules/auth/lib/pkce.test.ts b/packages/cli/src/modules/auth/lib/pkce.test.ts new file mode 100644 index 0000000000..09db60745a --- /dev/null +++ b/packages/cli/src/modules/auth/lib/pkce.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'node:crypto'; +import { generateVerifier, challengeFromVerifier } from './pkce'; + +describe('pkce', () => { + describe('generateVerifier', () => { + it('should generate verifiers with proper encoding and length', () => { + // Test default length + const defaultVerifier = generateVerifier(); + expect(defaultVerifier).toBeDefined(); + expect(typeof defaultVerifier).toBe('string'); + expect(defaultVerifier.length).toBeGreaterThan(0); + + // Test custom lengths + const shortVerifier = generateVerifier(32); + const longVerifier = generateVerifier(96); + expect(shortVerifier).toBeDefined(); + expect(longVerifier).toBeDefined(); + expect(shortVerifier.length).toBeGreaterThan(0); + expect(longVerifier.length).toBeGreaterThan(shortVerifier.length); + + // Test base64url encoding (no padding, proper characters) + const verifier = generateVerifier(); + expect(verifier).not.toContain('='); + expect(verifier).not.toContain('+'); + expect(verifier).not.toContain('/'); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + + // Test uniqueness + const verifier1 = generateVerifier(); + const verifier2 = generateVerifier(); + expect(verifier1).not.toBe(verifier2); + }); + + it('should enforce minimum and maximum length constraints', () => { + // Test minimum length enforcement + const minVerifier = generateVerifier(10); // Less than minimum + expect(minVerifier).toBeDefined(); + expect(minVerifier.length).toBeGreaterThanOrEqual(43); // 32 bytes = 43 base64url chars + + // Test maximum length enforcement + const maxVerifier = generateVerifier(200); // More than maximum + expect(maxVerifier).toBeDefined(); + expect(maxVerifier.length).toBeLessThanOrEqual(128); // 96 bytes = 128 base64url chars + }); + + it('should produce consistent results for same byte sequence', () => { + // Mock crypto.randomBytes to return predictable values + const mockBytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); + jest.spyOn(crypto, 'randomBytes').mockImplementation(_count => mockBytes); + + const verifier1 = generateVerifier(8); + const verifier2 = generateVerifier(8); + + expect(verifier1).toBe(verifier2); + + (crypto.randomBytes as jest.Mock).mockRestore(); + }); + }); + + describe('challengeFromVerifier', () => { + it('should generate challenges with proper encoding and consistency', () => { + const verifier = 'test-verifier-string'; + const challenge = challengeFromVerifier(verifier); + + // Basic properties + expect(challenge).toBeDefined(); + expect(typeof challenge).toBe('string'); + expect(challenge.length).toBe(43); // SHA-256 = 32 bytes = 43 base64url chars + + // Base64url encoding (no padding, proper characters) + expect(challenge).not.toContain('='); + expect(challenge).not.toContain('+'); + expect(challenge).not.toContain('/'); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + + // Consistency for same verifier + const challenge1 = challengeFromVerifier(verifier); + const challenge2 = challengeFromVerifier(verifier); + expect(challenge1).toBe(challenge2); + expect(challenge1).toBe(challenge); + + // Different challenges for different verifiers + const verifier2 = 'test-verifier-2'; + const challenge3 = challengeFromVerifier(verifier2); + expect(challenge3).not.toBe(challenge); + }); + + it('should handle edge cases for verifier length', () => { + // Empty verifier + const emptyChallenge = challengeFromVerifier(''); + expect(emptyChallenge).toBeDefined(); + expect(emptyChallenge.length).toBe(43); + + // Very long verifier + const longVerifier = 'a'.repeat(1000); + const longChallenge = challengeFromVerifier(longVerifier); + expect(longChallenge).toBeDefined(); + expect(longChallenge.length).toBe(43); // SHA-256 always produces 32 bytes + }); + + it('should produce RFC 7636 compliant challenge', () => { + // Test with a known verifier + const verifier = generateVerifier(); + const challenge = challengeFromVerifier(verifier); + + // Verify it's using SHA-256 correctly + const expectedHash = crypto + .createHash('sha256') + .update(verifier) + .digest(); + const expectedChallenge = expectedHash + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + expect(challenge).toBe(expectedChallenge); + }); + }); + + describe('PKCE flow integration', () => { + it('should generate valid verifier and challenge pair', () => { + const verifier = generateVerifier(); + const challenge = challengeFromVerifier(verifier); + + expect(verifier).toBeDefined(); + expect(challenge).toBeDefined(); + expect(verifier).not.toBe(challenge); + + // Verifier should be longer than challenge + expect(verifier.length).toBeGreaterThan(challenge.length); + }); + + it('should generate multiple unique pairs', () => { + const pair1 = { + verifier: generateVerifier(), + challenge: '', + }; + pair1.challenge = challengeFromVerifier(pair1.verifier); + + const pair2 = { + verifier: generateVerifier(), + challenge: '', + }; + pair2.challenge = challengeFromVerifier(pair2.verifier); + + expect(pair1.verifier).not.toBe(pair2.verifier); + expect(pair1.challenge).not.toBe(pair2.challenge); + }); + + it('should maintain one-to-one mapping between verifier and challenge', () => { + const verifier = generateVerifier(); + const challenge1 = challengeFromVerifier(verifier); + const challenge2 = challengeFromVerifier(verifier); + const challenge3 = challengeFromVerifier(verifier); + + // All challenges from same verifier should be identical + expect(challenge1).toBe(challenge2); + expect(challenge2).toBe(challenge3); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/pkce.ts b/packages/cli/src/modules/auth/lib/pkce.ts new file mode 100644 index 0000000000..fd5bba2d70 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/pkce.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'node:crypto'; + +function base64url(input: Buffer): string { + return input + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +export function generateVerifier(length = 64): string { + // length in bytes ~ 48 results in 64 base64url chars; keep within 43..128 chars + const bytes = crypto.randomBytes(Math.max(32, Math.min(96, length))); + return base64url(bytes); +} + +export function challengeFromVerifier(verifier: string): string { + const hash = crypto.createHash('sha256').update(verifier).digest(); + return base64url(hash); +} diff --git a/packages/cli/src/modules/auth/lib/prompt.test.ts b/packages/cli/src/modules/auth/lib/prompt.test.ts new file mode 100644 index 0000000000..acb28fc124 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/prompt.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import inquirer from 'inquirer'; +import { pickInstance } from './prompt'; +import * as storage from './storage'; + +jest.mock('inquirer'); +jest.mock('./storage'); + +const mockStorage = storage as jest.Mocked; +const mockInquirer = inquirer as jest.Mocked; + +describe('prompt', () => { + describe('pickInstance', () => { + const mockInstances = [ + { + name: 'production', + baseUrl: 'https://backstage.example.com', + clientId: 'prod-client', + issuedAt: Date.now(), + accessToken: 'prod-token', + accessTokenExpiresAt: Date.now() + 3600_000, + selected: true, + }, + { + name: 'staging', + baseUrl: 'https://staging.backstage.example.com', + clientId: 'staging-client', + issuedAt: Date.now(), + accessToken: 'staging-token', + accessTokenExpiresAt: Date.now() + 3600_000, + selected: false, + }, + { + name: 'local', + baseUrl: 'http://localhost:7007', + clientId: 'local-client', + issuedAt: Date.now(), + accessToken: 'local-token', + accessTokenExpiresAt: Date.now() + 3600_000, + selected: false, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return instance by name if provided', async () => { + mockStorage.getInstanceByName.mockResolvedValue(mockInstances[1]); + + const result = await pickInstance('staging'); + + expect(result).toEqual(mockInstances[1]); + expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('staging'); + expect(mockInquirer.prompt).not.toHaveBeenCalled(); + }); + + it('should prompt for instance and show selected instance with asterisk prefix', async () => { + // Test with production selected + mockStorage.getAllInstances.mockResolvedValue({ + instances: mockInstances, + selected: mockInstances[0], + }); + mockInquirer.prompt.mockResolvedValue({ choice: 'staging' }); + + const result = await pickInstance(); + + expect(mockInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'list', + name: 'choice', + message: 'Select instance:', + choices: [ + { + name: '* production (https://backstage.example.com)', + value: 'production', + }, + { + name: ' staging (https://staging.backstage.example.com)', + value: 'staging', + }, + { + name: ' local (http://localhost:7007)', + value: 'local', + }, + ], + default: 'production', + }, + ]); + expect(result).toEqual(mockInstances[1]); + + // Test with staging selected + mockStorage.getAllInstances.mockResolvedValue({ + instances: mockInstances, + selected: mockInstances[1], + }); + mockInquirer.prompt.mockResolvedValue({ choice: 'staging' }); + + await pickInstance(); + + expect(mockInquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + choices: [ + { + name: ' production (https://backstage.example.com)', + value: 'production', + }, + { + name: '* staging (https://staging.backstage.example.com)', + value: 'staging', + }, + { + name: ' local (http://localhost:7007)', + value: 'local', + }, + ], + default: 'staging', + }), + ]); + }); + + it('should throw error if no instances are available', async () => { + mockStorage.getAllInstances.mockResolvedValue({ + instances: [], + selected: undefined, + }); + + await expect(pickInstance()).rejects.toThrow( + 'No instances found. Run "auth login" to authenticate first.', + ); + }); + + it('should throw error if selected instance is not found', async () => { + mockStorage.getAllInstances.mockResolvedValue({ + instances: mockInstances, + selected: mockInstances[0], + }); + mockInquirer.prompt.mockResolvedValue({ choice: 'non-existent' }); + + await expect(pickInstance()).rejects.toThrow( + "Instance 'non-existent' not found", + ); + }); + + it('should handle single instance and use selected instance as default', async () => { + // Test single instance + const singleInstance = [mockInstances[0]]; + mockStorage.getAllInstances.mockResolvedValue({ + instances: singleInstance, + selected: mockInstances[0], + }); + mockInquirer.prompt.mockResolvedValue({ choice: 'production' }); + + const result = await pickInstance(); + + expect(result).toEqual(mockInstances[0]); + expect(mockInquirer.prompt).toHaveBeenCalled(); + + // Test default selection matches selected instance + const selectedInstance = mockInstances[2]; + mockStorage.getAllInstances.mockResolvedValue({ + instances: mockInstances, + selected: selectedInstance, + }); + mockInquirer.prompt.mockResolvedValue({ choice: 'local' }); + + await pickInstance(); + + expect(mockInquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + default: 'local', + }), + ]); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/prompt.ts b/packages/cli/src/modules/auth/lib/prompt.ts new file mode 100644 index 0000000000..46a7430548 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/prompt.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import inquirer from 'inquirer'; +import { getInstanceByName, getAllInstances, StoredInstance } from './storage'; + +export async function pickInstance(name?: string): Promise { + if (name) { + return getInstanceByName(name); + } + + const { instances, selected } = await getAllInstances(); + if (instances.length === 0) { + throw new Error( + 'No instances found. Run "auth login" to authenticate first.', + ); + } + return await promptForInstance(instances, selected); +} + +async function promptForInstance( + instances: StoredInstance[], + selected: StoredInstance | undefined, +): Promise { + const choices = instances.map(i => ({ + name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`, + value: i.name, + })); + + const { choice } = await inquirer.prompt<{ choice: string }>([ + { + type: 'list', + name: 'choice', + message: 'Select instance:', + choices, + default: selected?.name, + }, + ]); + + const instance = instances.find(i => i.name === choice); + if (!instance) { + throw new Error(`Instance '${choice}' not found`); + } + return instance; +} diff --git a/packages/cli/src/modules/auth/lib/secretStore.test.ts b/packages/cli/src/modules/auth/lib/secretStore.test.ts new file mode 100644 index 0000000000..6729f7c6a5 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/secretStore.test.ts @@ -0,0 +1,250 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('keytar', () => { + throw new Error('keytar not available'); +}); + +import fs from 'fs-extra'; +import path from 'node:path'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { getSecretStore, resetSecretStore } from './secretStore'; + +const mockDir = createMockDirectory(); + +describe('secretStore', () => { + beforeEach(() => { + mockDir.clear(); + process.env.XDG_DATA_HOME = mockDir.resolve('data'); + resetSecretStore(); + }); + + afterEach(() => { + delete process.env.XDG_DATA_HOME; + resetSecretStore(); + }); + + describe('FileSecretStore', () => { + it('should store and retrieve secrets', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + const result = await store.get('test-service', 'test-account'); + + expect(result).toBe('test-secret'); + }); + + it('should return undefined for non-existent secrets', async () => { + const store = await getSecretStore(); + const result = await store.get('test-service', 'test-account'); + + expect(result).toBeUndefined(); + }); + + it('should delete secrets', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + + let result = await store.get('test-service', 'test-account'); + expect(result).toBe('test-secret'); + + await store.delete('test-service', 'test-account'); + + result = await store.get('test-service', 'test-account'); + expect(result).toBeUndefined(); + }); + + it('should not throw when deleting non-existent secrets', async () => { + const store = await getSecretStore(); + + await expect( + store.delete('non-existent-service', 'non-existent-account'), + ).resolves.not.toThrow(); + }); + + it('should create files with correct directory structure', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + + const expectedDir = path.join( + mockDir.resolve('data'), + 'backstage-cli', + 'auth-secrets', + encodeURIComponent('test-service'), + ); + const expectedFile = path.join( + expectedDir, + `${encodeURIComponent('test-account')}.secret`, + ); + + expect(await fs.pathExists(expectedFile)).toBe(true); + expect(await fs.pathExists(expectedDir)).toBe(true); + }); + + it('should create files with correct permissions (0o600)', async () => { + // File permissions are not reliably enforced on Windows + if (process.platform === 'win32') { + return; + } + + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + + const expectedFile = path.join( + mockDir.resolve('data'), + 'backstage-cli', + 'auth-secrets', + encodeURIComponent('test-service'), + `${encodeURIComponent('test-account')}.secret`, + ); + + const stats = await fs.stat(expectedFile); + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('should encode service and account names in file path', async () => { + const store = await getSecretStore(); + await store.set('my-service/test', 'my-account@test', 'test-secret'); + + const result = await store.get('my-service/test', 'my-account@test'); + expect(result).toBe('test-secret'); + + const expectedFile = path.join( + mockDir.resolve('data'), + 'backstage-cli', + 'auth-secrets', + encodeURIComponent('my-service/test'), + `${encodeURIComponent('my-account@test')}.secret`, + ); + expect(await fs.pathExists(expectedFile)).toBe(true); + }); + + it('should handle unicode characters in service and account names', async () => { + const store = await getSecretStore(); + await store.set('service-测试', 'account-🚀', 'test-secret'); + + const result = await store.get('service-测试', 'account-🚀'); + expect(result).toBe('test-secret'); + }); + + it('should handle multiple secrets for same service', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'account1', 'secret1'); + await store.set('test-service', 'account2', 'secret2'); + + const result1 = await store.get('test-service', 'account1'); + const result2 = await store.get('test-service', 'account2'); + + expect(result1).toBe('secret1'); + expect(result2).toBe('secret2'); + }); + + it('should handle multiple services', async () => { + const store = await getSecretStore(); + await store.set('service1', 'account', 'secret1'); + await store.set('service2', 'account', 'secret2'); + + const result1 = await store.get('service1', 'account'); + const result2 = await store.get('service2', 'account'); + + expect(result1).toBe('secret1'); + expect(result2).toBe('secret2'); + }); + + it('should update existing secrets', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'old-secret'); + await store.set('test-service', 'test-account', 'new-secret'); + + const result = await store.get('test-service', 'test-account'); + expect(result).toBe('new-secret'); + }); + + it('should handle empty string secrets', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', ''); + + const result = await store.get('test-service', 'test-account'); + expect(result).toBe(''); + }); + + it('should handle very long secrets', async () => { + const store = await getSecretStore(); + const longSecret = 'a'.repeat(10000); + await store.set('test-service', 'test-account', longSecret); + + const result = await store.get('test-service', 'test-account'); + expect(result).toBe(longSecret); + }); + + it('should use XDG_DATA_HOME when set', async () => { + const customDataHome = mockDir.resolve('custom-data'); + process.env.XDG_DATA_HOME = customDataHome; + resetSecretStore(); + + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + + const expectedFile = path.join( + customDataHome, + 'backstage-cli', + 'auth-secrets', + encodeURIComponent('test-service'), + `${encodeURIComponent('test-account')}.secret`, + ); + expect(await fs.pathExists(expectedFile)).toBe(true); + + const result = await store.get('test-service', 'test-account'); + expect(result).toBe('test-secret'); + }); + }); + + describe('getSecretStore singleton', () => { + it('should return the same instance on multiple calls', async () => { + const store1 = await getSecretStore(); + const store2 = await getSecretStore(); + + expect(store1).toBe(store2); + }); + + it('should create new instance after reset', async () => { + const store1 = await getSecretStore(); + resetSecretStore(); + const store2 = await getSecretStore(); + + expect(store1).not.toBe(store2); + }); + }); + + describe('fallback behavior', () => { + it('should fall back to FileSecretStore when keytar is not available', async () => { + const store = await getSecretStore(); + await store.set('test-service', 'test-account', 'test-secret'); + + const result = await store.get('test-service', 'test-account'); + expect(result).toBe('test-secret'); + + const expectedFile = path.join( + mockDir.resolve('data'), + 'backstage-cli', + 'auth-secrets', + encodeURIComponent('test-service'), + `${encodeURIComponent('test-account')}.secret`, + ); + expect(await fs.pathExists(expectedFile)).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/secretStore.ts b/packages/cli/src/modules/auth/lib/secretStore.ts new file mode 100644 index 0000000000..55fac878b7 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/secretStore.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +async function loadKeytar(): Promise { + try { + // eslint-disable-next-line import/no-extraneous-dependencies, @backstage/no-undeclared-imports + const keytar = require('keytar') as typeof import('keytar'); + if (keytar && typeof keytar.getPassword === 'function') { + return keytar; + } + } catch { + // keytar not available + } + return undefined; +} + +class KeytarSecretStore implements SecretStore { + private readonly keytar: typeof import('keytar'); + constructor(keytar: typeof import('keytar')) { + this.keytar = keytar; + } + async get(service: string, account: string): Promise { + const result = await this.keytar.getPassword(service, account); + return result ?? undefined; + } + async set(service: string, account: string, secret: string): Promise { + await this.keytar.setPassword(service, account, secret); + } + async delete(service: string, account: string): Promise { + await this.keytar.deletePassword(service, account); + } +} + +class FileSecretStore implements SecretStore { + private readonly baseDir: string; + constructor() { + const root = + process.env.XDG_DATA_HOME || + (process.platform === 'win32' + ? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + : path.join(os.homedir(), '.local', 'share')); + this.baseDir = path.join(root, 'backstage-cli', 'auth-secrets'); + } + private filePath(service: string, account: string): string { + return path.join( + this.baseDir, + encodeURIComponent(service), + `${encodeURIComponent(account)}.secret`, + ); + } + async get(service: string, account: string): Promise { + const file = this.filePath(service, account); + if (!(await fs.pathExists(file))) return undefined; + return await fs.readFile(file, 'utf8'); + } + async set(service: string, account: string, secret: string): Promise { + const file = this.filePath(service, account); + await fs.ensureDir(path.dirname(file)); + await fs.writeFile(file, secret, { encoding: 'utf8', mode: 0o600 }); + } + async delete(service: string, account: string): Promise { + const file = this.filePath(service, account); + await fs.remove(file); + } +} + +let singleton: SecretStore | undefined; + +export async function getSecretStore(): Promise { + if (!singleton) { + const keytar = await loadKeytar(); + if (keytar) { + singleton = new KeytarSecretStore(keytar); + } else { + singleton = new FileSecretStore(); + } + } + return singleton; +} + +/** + * Reset the singleton instance (for testing purposes only) + * @internal + */ +export function resetSecretStore(): void { + singleton = undefined; +} diff --git a/packages/cli/src/modules/auth/lib/storage.test.ts b/packages/cli/src/modules/auth/lib/storage.test.ts new file mode 100644 index 0000000000..07ffba9510 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/storage.test.ts @@ -0,0 +1,420 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import path from 'node:path'; +import { NotFoundError } from '@backstage/errors'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { + getAllInstances, + getSelectedInstance, + getInstanceByName, + upsertInstance, + removeInstance, + setSelectedInstance, + withMetadataLock, + StoredInstance, +} from './storage'; + +const mockDir = createMockDirectory(); + +describe('storage', () => { + const mockInstance1: StoredInstance = { + name: 'production', + baseUrl: 'https://backstage.example.com', + clientId: 'prod-client', + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + 3600_000, + selected: true, + }; + + const mockInstance2: StoredInstance = { + name: 'staging', + baseUrl: 'https://staging.backstage.example.com', + clientId: 'staging-client', + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + 3600_000, + selected: false, + }; + + beforeEach(() => { + mockDir.clear(); + process.env.XDG_CONFIG_HOME = mockDir.resolve('config'); + }); + + afterEach(() => { + delete process.env.XDG_CONFIG_HOME; + }); + + describe('getAllInstances', () => { + it('should return empty array if file does not exist or is empty', async () => { + const result1 = await getAllInstances(); + expect(result1).toEqual({ instances: [], selected: undefined }); + + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': '', + }); + + const result2 = await getAllInstances(); + expect(result2).toEqual({ instances: [], selected: undefined }); + }); + + it('should parse and return instances from YAML', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + selected: true + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} +`, + }); + + const result = await getAllInstances(); + + expect(result.instances).toHaveLength(2); + expect(result.selected?.name).toBe('production'); + }); + + it('should select first instance if none marked as selected', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} +`, + }); + + const result = await getAllInstances(); + + expect(result.selected?.name).toBe('production'); + }); + + it('should return empty array if YAML parsing fails', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': 'invalid: yaml: [', + }); + + const result = await getAllInstances(); + + expect(result).toEqual({ instances: [], selected: undefined }); + }); + + it('should normalize selected property across instances', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + selected: true + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} + selected: true +`, + }); + + const result = await getAllInstances(); + + const selectedCount = result.instances.filter(i => i.selected).length; + expect(selectedCount).toBe(1); + }); + }); + + describe('getSelectedInstance', () => { + it('should return instance by name if provided', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} +`, + }); + + const result = await getSelectedInstance('production'); + + expect(result.name).toBe('production'); + }); + + it('should return selected instance if no name provided', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} + selected: true +`, + }); + + const result = await getSelectedInstance(); + + expect(result.name).toBe('staging'); + }); + + it('should throw error if no instances exist', async () => { + await expect(getSelectedInstance()).rejects.toThrow( + 'No instances found. Run "auth login" to authenticate first.', + ); + }); + }); + + describe('getInstanceByName', () => { + it('should return instance with matching name', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} +`, + }); + + const result = await getInstanceByName('production'); + + expect(result.name).toBe('production'); + }); + + it('should throw NotFoundError if instance does not exist', async () => { + await expect(getInstanceByName('nonexistent')).rejects.toThrow( + NotFoundError, + ); + await expect(getInstanceByName('nonexistent')).rejects.toThrow( + "Instance 'nonexistent' not found", + ); + }); + }); + + describe('upsertInstance', () => { + it('should add new instance if it does not exist', async () => { + await upsertInstance(mockInstance1); + + const result = await getAllInstances(); + expect(result.instances).toHaveLength(1); + expect(result.instances[0].name).toBe('production'); + }); + + it('should update existing instance', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} +`, + }); + + const updatedInstance = { + ...mockInstance1, + clientId: 'updated-client', + }; + + await upsertInstance(updatedInstance); + + const result = await getInstanceByName('production'); + expect(result.clientId).toBe('updated-client'); + }); + }); + + describe('removeInstance', () => { + it('should remove instance with matching name', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} +`, + }); + + await removeInstance('production'); + + const result = await getAllInstances(); + expect(result.instances).toHaveLength(1); + expect(result.instances[0].name).toBe('staging'); + }); + + it('should do nothing if instance does not exist', async () => { + await removeInstance('nonexistent'); + + const result = await getAllInstances(); + expect(result.instances).toHaveLength(0); + }); + }); + + describe('setSelectedInstance', () => { + it('should set selected instance and unselect others', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production + baseUrl: https://backstage.example.com + clientId: prod-client + issuedAt: ${mockInstance1.issuedAt} + + accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt} + selected: true + - name: staging + baseUrl: https://staging.backstage.example.com + clientId: staging-client + issuedAt: ${mockInstance2.issuedAt} + + accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt} +`, + }); + + await setSelectedInstance('staging'); + + const result = await getAllInstances(); + expect(result.selected?.name).toBe('staging'); + + const prodInstance = result.instances.find(i => i.name === 'production'); + expect(prodInstance?.selected).toBe(false); + }); + + it('should throw error if instance does not exist', async () => { + await expect(setSelectedInstance('nonexistent')).rejects.toThrow( + "Unknown instance 'nonexistent'", + ); + }); + }); + + describe('withMetadataLock', () => { + it('should acquire and release lock', async () => { + const callback = jest.fn().mockResolvedValue('result'); + const result = await withMetadataLock(callback); + + expect(callback).toHaveBeenCalled(); + expect(result).toBe('result'); + }); + + it('should release lock even if callback throws', async () => { + const error = new Error('Test error'); + const callback = jest.fn().mockRejectedValue(error); + + await expect(withMetadataLock(callback)).rejects.toThrow(error); + + // Lock should still be released, allowing subsequent calls + const callback2 = jest.fn().mockResolvedValue('result'); + await expect(withMetadataLock(callback2)).resolves.toBe('result'); + }); + }); + + describe('file path resolution', () => { + it('should use XDG_CONFIG_HOME when set', async () => { + const customConfigHome = mockDir.resolve('custom-config'); + process.env.XDG_CONFIG_HOME = customConfigHome; + + await upsertInstance(mockInstance1); + + const result = await getAllInstances(); + expect(result.instances).toHaveLength(1); + expect(result.instances[0].name).toBe('production'); + + // Verify file was created in custom location + const expectedFile = path.join( + customConfigHome, + 'backstage-cli', + 'auth-instances.yaml', + ); + expect(await fs.pathExists(expectedFile)).toBe(true); + }); + + it('should create files with correct permissions (0o600)', async () => { + // File permissions are not reliably enforced on Windows + if (process.platform === 'win32') { + return; + } + + await upsertInstance(mockInstance1); + + const file = path.join( + mockDir.resolve('config'), + 'backstage-cli', + 'auth-instances.yaml', + ); + const stats = await fs.stat(file); + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('should handle invalid schema and missing fields gracefully', async () => { + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: "" + baseUrl: not-a-url + clientId: "" +`, + }); + + const result1 = await getAllInstances(); + expect(result1.instances).toHaveLength(0); + + mockDir.setContent({ + 'config/backstage-cli/auth-instances.yaml': `instances: + - name: production +`, + }); + + const result2 = await getAllInstances(); + expect(result2.instances).toHaveLength(0); + }); + }); +}); diff --git a/packages/cli/src/modules/auth/lib/storage.ts b/packages/cli/src/modules/auth/lib/storage.ts new file mode 100644 index 0000000000..c2eb153112 --- /dev/null +++ b/packages/cli/src/modules/auth/lib/storage.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NotFoundError } from '@backstage/errors'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import lockfile from 'proper-lockfile'; +import YAML from 'yaml'; +import { z } from 'zod'; + +const METADATA_FILE = 'auth-instances.yaml'; + +const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/; + +const storedInstanceSchema = z.object({ + name: z + .string() + .min(1) + .regex(INSTANCE_NAME_PATTERN, 'Instance name contains invalid characters'), + baseUrl: z.string().url(), + clientId: z.string().min(1), + issuedAt: z.number().int().nonnegative(), + accessTokenExpiresAt: z.number().int().nonnegative(), + selected: z.boolean().optional(), +}); + +export type StoredInstance = z.infer; + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), +}); + +function getMetadataFilePath(): string { + const root = + process.env.XDG_CONFIG_HOME || + (process.platform === 'win32' + ? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + : path.join(os.homedir(), '.config')); + + return path.join(root, 'backstage-cli', METADATA_FILE); +} + +async function readAll(): Promise<{ instances: StoredInstance[] }> { + const file = getMetadataFilePath(); + if (!(await fs.pathExists(file))) { + return { instances: [] }; + } + const text = await fs.readFile(file, 'utf8'); + if (!text.trim()) { + return { instances: [] }; + } + try { + const doc = YAML.parse(text); + const parsed = authYamlSchema.safeParse(doc); + if (parsed.success) { + return parsed.data; + } + return { instances: [] }; + } catch { + return { instances: [] }; + } +} + +async function writeAll(data: { instances: StoredInstance[] }): Promise { + const file = getMetadataFilePath(); + await fs.ensureDir(path.dirname(file)); + const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false }); + await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 }); +} + +export async function getAllInstances(): Promise<{ + instances: StoredInstance[]; + selected: StoredInstance | undefined; +}> { + const { instances } = await readAll(); + const selected = instances.find(i => i.selected) ?? instances[0]; + return { + // Normalize selection prop + instances: instances.map(i => ({ + ...i, + selected: i.name === selected.name, + })), + selected, + }; +} + +export async function getSelectedInstance( + instanceName?: string, +): Promise { + if (instanceName) { + return await getInstanceByName(instanceName); + } + const { selected } = await getAllInstances(); + if (!selected) { + throw new Error( + 'No instances found. Run "auth login" to authenticate first.', + ); + } + return selected; +} + +export async function getInstanceByName(name: string): Promise { + const { instances } = await readAll(); + const instance = instances.find(i => i.name === name); + if (!instance) { + throw new NotFoundError(`Instance '${name}' not found`); + } + return instance; +} + +export async function upsertInstance(instance: StoredInstance): Promise { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instance.name); + if (idx === -1) { + data.instances.push(instance); + } else { + data.instances[idx] = instance; + } + await writeAll(data); +} + +export async function removeInstance(name: string): Promise { + const data = await readAll(); + const next = data.instances.filter(i => i.name !== name); + if (next.length !== data.instances.length) { + await writeAll({ instances: next }); + } +} + +export async function setSelectedInstance(name: string): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + let found = false; + data.instances = data.instances.map(i => { + if (i.name === name) { + found = true; + return { ...i, selected: true }; + } + const { selected, ...rest } = i; + return { ...rest, selected: false }; + }); + if (!found) { + throw new Error(`Unknown instance '${name}'`); + } + await writeAll(data); + }); +} + +export async function withMetadataLock(fn: () => Promise): Promise { + const file = getMetadataFilePath(); + await fs.ensureDir(path.dirname(file)); + if (!(await fs.pathExists(file))) { + await fs.writeFile(file, '', { encoding: 'utf8', mode: 0o600 }); + } + const release = await lockfile.lock(file, { + retries: { retries: 5, factor: 1.5, minTimeout: 100, maxTimeout: 1000 }, + }); + try { + return await fn(); + } finally { + await release(); + } +} diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index fb621fb6f9..dea9efbaaa 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -1120,6 +1120,124 @@ describe('OidcRouter', () => { }); }); + describe('CIMD metadata endpoint', () => { + it('should return 404 when CIMD is not enabled', async () => { + const { router } = await createRouter(databaseId); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(router.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .get('/api/auth/.well-known/oauth-client/cli.json') + .expect(404); + + expect(response.body).toEqual({ + error: 'not_found', + error_description: 'Client ID metadata documents not enabled', + }); + }); + + it('should return CIMD document when enabled', async () => { + const knex = await databases.init(databaseId); + + await knex.migrate.latest({ + directory: resolvePackagePath( + '@backstage/plugin-auth-backend', + 'migrations', + ), + }); + + const authDatabase = AuthDatabase.create({ + getClient: async () => knex, + }); + + const oidcDatabase = await OidcDatabase.create({ + database: authDatabase, + }); + + const userInfoDatabase = await UserInfoDatabase.create({ + database: authDatabase, + }); + + const mockTokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + } as unknown as jest.Mocked; + + const oidcRouter = OidcRouter.create({ + auth: mockServices.auth.mock(), + tokenIssuer: mockTokenIssuer, + baseUrl: 'http://localhost:7007/api/auth', + appUrl: 'http://localhost:3000', + logger: mockServices.logger.mock(), + userInfo: userInfoDatabase, + oidc: oidcDatabase, + httpAuth: mockServices.httpAuth.mock(), + config: mockServices.rootConfig({ + data: { + auth: { + experimentalClientIdMetadataDocuments: { + enabled: true, + }, + }, + }, + }), + }); + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + httpRouter.use(oidcRouter.getRouter()); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + const response = await request(server) + .get('/api/auth/.well-known/oauth-client/cli.json') + .expect(200); + + expect(response.body).toEqual({ + client_id: + 'http://localhost:7007/api/auth/.well-known/oauth-client/cli.json', + client_name: 'Backstage CLI', + redirect_uris: ['http://127.0.0.1:8055/callback'], + response_types: ['code'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + scope: 'openid offline_access', + }); + }); + }); + describe('CIMD authorization', () => { it('should enable authorization routes when only CIMD is enabled (not DCR)', async () => { const cimdClientId = 'https://example.com/oauth-client'; diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index 746036806c..dc921a9489 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -145,6 +145,7 @@ export class OidcRouter { private readonly appUrl: string; private readonly httpAuth: HttpAuthService; private readonly config: RootConfigService; + private readonly baseUrl: string; private constructor( oidc: OidcService, @@ -153,6 +154,7 @@ export class OidcRouter { appUrl: string, httpAuth: HttpAuthService, config: RootConfigService, + baseUrl: string, ) { this.oidc = oidc; this.logger = logger; @@ -160,6 +162,7 @@ export class OidcRouter { this.appUrl = appUrl; this.httpAuth = httpAuth; this.config = config; + this.baseUrl = baseUrl; } static create(options: { @@ -181,6 +184,7 @@ export class OidcRouter { options.appUrl, options.httpAuth, options.config, + options.baseUrl, ); } @@ -204,6 +208,32 @@ export class OidcRouter { res.json({ keys }); }); + // CIMD metadata endpoint for the Backstage CLI + // Automatically available when CIMD is enabled + router.get('/.well-known/oauth-client/cli.json', (_req, res) => { + const cimdEnabled = this.config.getOptionalBoolean( + 'auth.experimentalClientIdMetadataDocuments.enabled', + ); + + if (!cimdEnabled) { + res.status(404).json({ + error: 'not_found', + error_description: 'Client ID metadata documents not enabled', + }); + return; + } + + res.json({ + client_id: `${this.baseUrl}/.well-known/oauth-client/cli.json`, + client_name: 'Backstage CLI', + redirect_uris: ['http://127.0.0.1:8055/callback'], + response_types: ['code'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + scope: 'openid offline_access', + }); + }); + // UserInfo endpoint // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo // Returns claims about the authenticated user using an access token diff --git a/yarn.lock b/yarn.lock index 4633a41e45..b1de7684dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3329,6 +3329,7 @@ __metadata: "@types/jest": "npm:^30.0.0" "@types/node": "npm:^22.13.14" "@types/npm-packlist": "npm:^3.0.0" + "@types/proper-lockfile": "npm:^4" "@types/recursive-readdir": "npm:^2.2.0" "@types/rollup-plugin-peer-deps-external": "npm:^2.2.0" "@types/rollup-plugin-postcss": "npm:^3.1.4" @@ -3379,6 +3380,7 @@ __metadata: jest-css-modules: "npm:^2.1.0" jsdom: "npm:^27.1.0" json-schema: "npm:^0.4.0" + keytar: "npm:^7.9.0" lodash: "npm:^4.17.21" mini-css-extract-plugin: "npm:^2.4.2" minimatch: "npm:^10.2.1" @@ -3392,6 +3394,7 @@ __metadata: postcss: "npm:^8.1.0" postcss-import: "npm:^16.1.0" process: "npm:^0.11.10" + proper-lockfile: "npm:^4.1.2" raw-loader: "npm:^4.0.2" react-dev-utils: "npm:^12.0.0-next.60" react-refresh: "npm:^0.17.0" @@ -3435,6 +3438,9 @@ __metadata: terser-webpack-plugin: ^5.1.3 webpack: ~5.105.0 webpack-dev-server: ^5.0.0 + dependenciesMeta: + keytar: + optional: true peerDependenciesMeta: "@jest/environment-jsdom-abstract": optional: true @@ -22784,6 +22790,15 @@ __metadata: languageName: node linkType: hard +"@types/proper-lockfile@npm:^4": + version: 4.1.4 + resolution: "@types/proper-lockfile@npm:4.1.4" + dependencies: + "@types/retry": "npm:*" + checksum: 10/b0d1b8e84a563b2c5f869f7ff7542b1d83dec03d1c9d980847cbb189865f44b4a854673cdde59767e41bcb8c31932e613ac43822d358a6f8eede6b79ccfceb1d + languageName: node + linkType: hard + "@types/protocol-buffers-schema@npm:^3.4.3": version: 3.4.3 resolution: "@types/protocol-buffers-schema@npm:3.4.3" @@ -22972,6 +22987,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:*": + version: 0.12.5 + resolution: "@types/retry@npm:0.12.5" + checksum: 10/3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2 + languageName: node + linkType: hard + "@types/retry@npm:0.12.0": version: 0.12.0 resolution: "@types/retry@npm:0.12.0" @@ -37674,6 +37696,17 @@ __metadata: languageName: node linkType: hard +"keytar@npm:^7.9.0": + version: 7.9.0 + resolution: "keytar@npm:7.9.0" + dependencies: + node-addon-api: "npm:^4.3.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.0.1" + checksum: 10/904795bc304f8ad89b80f915c869a941a383312b58584212a199473d18647035cfda92a9c53e6c53bf13ea0fed23037c9597eb418a5c71ee9454f140f026fac9 + languageName: node + linkType: hard + "keyv@npm:*, keyv@npm:^5.2.1": version: 5.6.0 resolution: "keyv@npm:5.6.0" @@ -40726,6 +40759,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^4.3.0": + version: 4.3.0 + resolution: "node-addon-api@npm:4.3.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10/d3b38d16cb9ad0714d965331d0e38cef1c27750c2c3343cd3464a9ed8158501a2910ccbf2fd9fdc476e806a19dbc9e0524ff9d66a7c779d42a9752a63ba30b80 + languageName: node + linkType: hard + "node-addon-api@npm:^8.0.0, node-addon-api@npm:^8.2.2, node-addon-api@npm:^8.3.0, node-addon-api@npm:^8.3.1": version: 8.5.0 resolution: "node-addon-api@npm:8.5.0" @@ -43605,7 +43647,7 @@ __metadata: languageName: node linkType: hard -"prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.3": +"prebuild-install@npm:^7.0.1, prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" dependencies: