From 12fa965e67fd13cbaf7c71f58a3463e2bb18379b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 13:17:09 +0100 Subject: [PATCH 1/8] cli-node: add CliAuth class for shared CLI authentication Introduces a class-based authentication management API in @backstage/cli-node that reads the on-disk instance store, transparently refreshes expired tokens, and provides a convenient surface for other CLI modules to consume. The split keeps filesystem-based instance selection and writes owned by cli-module-auth, while reading and consuming the current instance is available through CliAuth in cli-node. Migrates cli-module-actions to use the new API and deprecates the ad-hoc function exports from cli-module-auth. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/cli-module-actions-use-cli-auth.md | 5 + .../cli-module-auth-deprecate-exports.md | 5 + .changeset/cli-node-auth-api.md | 5 + .../src/commands/sourcesAdd.ts | 15 +- .../src/commands/sourcesList.ts | 11 +- .../src/commands/sourcesRemove.ts | 15 +- .../src/lib/ActionsClient.test.ts | 12 +- .../src/lib/ActionsClient.ts | 2 +- .../src/lib/resolveAuth.test.ts | 111 ++++----- .../cli-module-actions/src/lib/resolveAuth.ts | 29 +-- packages/cli-module-auth/report.api.md | 45 ++-- .../src/commands/printToken.ts | 19 +- packages/cli-module-auth/src/commands/show.ts | 22 +- packages/cli-module-auth/src/index.ts | 8 +- packages/cli-module-auth/src/lib/auth.ts | 15 +- packages/cli-module-auth/src/lib/http.ts | 26 +-- .../cli-module-auth/src/lib/secretStore.ts | 8 +- packages/cli-module-auth/src/lib/storage.ts | 19 +- packages/cli-node/package.json | 3 + packages/cli-node/report.api.md | 47 ++++ packages/cli-node/src/auth/CliAuth.test.ts | 210 ++++++++++++++++++ packages/cli-node/src/auth/CliAuth.ts | 156 +++++++++++++ packages/cli-node/src/auth/httpJson.ts | 41 ++++ packages/cli-node/src/auth/index.ts | 20 ++ packages/cli-node/src/auth/secretStore.ts | 112 ++++++++++ packages/cli-node/src/auth/storage.ts | 144 ++++++++++++ packages/cli-node/src/index.ts | 1 + yarn.lock | 4 + 28 files changed, 866 insertions(+), 244 deletions(-) create mode 100644 .changeset/cli-module-actions-use-cli-auth.md create mode 100644 .changeset/cli-module-auth-deprecate-exports.md create mode 100644 .changeset/cli-node-auth-api.md create mode 100644 packages/cli-node/src/auth/CliAuth.test.ts create mode 100644 packages/cli-node/src/auth/CliAuth.ts create mode 100644 packages/cli-node/src/auth/httpJson.ts create mode 100644 packages/cli-node/src/auth/index.ts create mode 100644 packages/cli-node/src/auth/secretStore.ts create mode 100644 packages/cli-node/src/auth/storage.ts diff --git a/.changeset/cli-module-actions-use-cli-auth.md b/.changeset/cli-module-actions-use-cli-auth.md new file mode 100644 index 0000000000..705ebac220 --- /dev/null +++ b/.changeset/cli-module-actions-use-cli-auth.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-actions': patch +--- + +Migrated to use `CliAuth` from `@backstage/cli-node` for authentication instead of importing individual functions from `@backstage/cli-module-auth`. diff --git a/.changeset/cli-module-auth-deprecate-exports.md b/.changeset/cli-module-auth-deprecate-exports.md new file mode 100644 index 0000000000..5a8c79feef --- /dev/null +++ b/.changeset/cli-module-auth-deprecate-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Deprecated `getSelectedInstance`, `getInstanceConfig`, `accessTokenNeedsRefresh`, `refreshAccessToken`, `getSecretStore`, and `httpJson` exports in favor of the new `CliAuth` class and shared utilities from `@backstage/cli-node`. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md new file mode 100644 index 0000000000..bed9681178 --- /dev/null +++ b/.changeset/cli-node-auth-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': minor +--- + +Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend. Also added `httpJson`, `getSecretStore`, `SecretStore`, `StoredInstance`, and `HttpInit` exports. diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index 2a8135b63f..6d9ea2b524 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -15,12 +15,8 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { updateInstanceConfig } from '@backstage/cli-module-auth'; export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,9 +30,8 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; - const instance = await getSelectedInstance(); - const existing = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const existing = (await auth.getConfig('pluginSources')) ?? []; if (existing.includes(pluginId)) { process.stderr.write( @@ -45,7 +40,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(instance.name, 'pluginSources', [ + await updateInstanceConfig(auth.instanceName, 'pluginSources', [ ...existing, pluginId, ]); diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts index 68b368efd5..23a6a6449f 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -15,18 +15,13 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { cli({ help: info }, undefined, args); - const instance = await getSelectedInstance(); - const sources = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const sources = (await auth.getConfig('pluginSources')) ?? []; if (!sources.length) { process.stderr.write('No plugin sources configured.\n'); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts index 731abe790d..81a2d5abf3 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -15,12 +15,8 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { updateInstanceConfig } from '@backstage/cli-module-auth'; export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,9 +30,8 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; - const instance = await getSelectedInstance(); - const existing = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const existing = (await auth.getConfig('pluginSources')) ?? []; if (!existing.includes(pluginId)) { process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); @@ -44,7 +39,7 @@ export default async ({ args, info }: CliCommandContext) => { } await updateInstanceConfig( - instance.name, + auth.instanceName, 'pluginSources', existing.filter(s => s !== pluginId), ); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts index 61526873c9..ecf7861f26 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.test.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -15,11 +15,15 @@ */ import { ActionsClient } from './ActionsClient'; -import { httpJson } from '@backstage/cli-module-auth'; +import { httpJson } from '@backstage/cli-node'; -jest.mock('@backstage/cli-module-auth', () => ({ - httpJson: jest.fn(), -})); +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + httpJson: jest.fn(), + }; +}); const mockHttpJson = httpJson as jest.MockedFunction; diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts index aa04902b2f..f7d284ad1a 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { httpJson } from '@backstage/cli-module-auth'; +import { httpJson } from '@backstage/cli-node'; export type ActionDef = { id: string; diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index f2702cf757..21cf6ebc38 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -15,39 +15,17 @@ */ import { resolveAuth } from './resolveAuth'; -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +import { CliAuth, type StoredInstance } from '@backstage/cli-node'; -jest.mock('@backstage/cli-module-auth', () => ({ - getSelectedInstance: jest.fn(), - getInstanceConfig: jest.fn(), - accessTokenNeedsRefresh: jest.fn(), - refreshAccessToken: jest.fn(), - getSecretStore: jest.fn(), -})); +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + CliAuth: { create: jest.fn() }, + }; +}); -const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction< - typeof getSelectedInstance ->; -const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction< - typeof getInstanceConfig ->; -const mockAccessTokenNeedsRefresh = - accessTokenNeedsRefresh as jest.MockedFunction< - typeof accessTokenNeedsRefresh - >; -const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction< - typeof refreshAccessToken ->; -const mockGetSecretStore = getSecretStore as jest.MockedFunction< - typeof getSecretStore ->; +const mockCreate = CliAuth.create as jest.MockedFunction; describe('resolveAuth', () => { const mockInstance: StoredInstance = { @@ -58,27 +36,22 @@ describe('resolveAuth', () => { accessTokenExpiresAt: Date.now() + 3600_000, }; - const mockSecretStore = { - get: jest.fn(), - set: jest.fn(), - delete: jest.fn(), - }; - beforeEach(() => { jest.clearAllMocks(); - mockGetSelectedInstance.mockResolvedValue(mockInstance); - mockAccessTokenNeedsRefresh.mockReturnValue(false); - mockGetSecretStore.mockResolvedValue(mockSecretStore); - mockSecretStore.get.mockResolvedValue('test-access-token'); - mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']); }); it('resolves auth with the selected instance and stored token', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), + } as unknown as CliAuth); + const result = await resolveAuth(); - expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined); - expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance); - expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(mockCreate).toHaveBeenCalledWith({ instanceName: undefined }); expect(result).toEqual({ instance: mockInstance, accessToken: 'test-access-token', @@ -86,28 +59,32 @@ describe('resolveAuth', () => { }); }); - it('passes instance name flag to getSelectedInstance', async () => { + it('passes instance name flag to CliAuth.create', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: jest.fn().mockResolvedValue([]), + } as unknown as CliAuth); + await resolveAuth('staging'); - expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging'); + expect(mockCreate).toHaveBeenCalledWith({ instanceName: 'staging' }); }); - it('refreshes the access token when it is about to expire', async () => { - const refreshedInstance = { - ...mockInstance, - accessTokenExpiresAt: Date.now() + 7200_000, - }; - mockAccessTokenNeedsRefresh.mockReturnValue(true); - mockRefreshAccessToken.mockResolvedValue(refreshedInstance); - - const result = await resolveAuth(); - - expect(mockRefreshAccessToken).toHaveBeenCalledWith('production'); - expect(result.instance).toBe(refreshedInstance); - }); - - it('throws when no access token is stored', async () => { - mockSecretStore.get.mockResolvedValue(undefined); + it('throws when getAccessToken fails', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest + .fn() + .mockRejectedValue( + new Error('No access token found. Run "auth login" to authenticate.'), + ), + getConfig: jest.fn().mockResolvedValue([]), + } as unknown as CliAuth); await expect(resolveAuth()).rejects.toThrow( 'No access token found. Run "auth login" to authenticate.', @@ -115,7 +92,13 @@ describe('resolveAuth', () => { }); it('returns empty plugin sources when none are configured', async () => { - mockGetInstanceConfig.mockResolvedValue(undefined); + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: jest.fn().mockResolvedValue(undefined), + } as unknown as CliAuth); const result = await resolveAuth(); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts index 11fb646545..18664b1b55 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -14,35 +14,16 @@ * limitations under the License. */ -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +import { CliAuth, type StoredInstance } from '@backstage/cli-node'; export async function resolveAuth(instanceFlag?: string): Promise<{ instance: StoredInstance; accessToken: string; pluginSources: string[]; }> { - let instance = await getSelectedInstance(instanceFlag); + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); + const pluginSources = (await auth.getConfig('pluginSources')) ?? []; - 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.'); - } - - const pluginSources = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; - - return { instance, accessToken, pluginSources }; + return { instance: auth.instance, accessToken, pluginSources }; } diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index a3b98e2f02..39011f0bac 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -4,61 +4,44 @@ ```ts import { CliModule } from '@backstage/cli-node'; +import { getSecretStore } from '@backstage/cli-node'; +import { HttpInit } from '@backstage/cli-node'; +import { httpJson } from '@backstage/cli-node'; +import { SecretStore } from '@backstage/cli-node'; +import { StoredInstance } from '@backstage/cli-node'; -// @public (undocumented) +// @public @deprecated (undocumented) export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; // @public (undocumented) const _default: CliModule; export default _default; -// @public (undocumented) +// @public @deprecated (undocumented) export function getInstanceConfig( instanceName: string, key: string, ): Promise; -// @public (undocumented) -export function getSecretStore(): Promise; +export { getSecretStore }; -// @public (undocumented) +// @public @deprecated (undocumented) export function getSelectedInstance( instanceName?: string, ): Promise; -// @public (undocumented) -export type HttpInit = { - headers?: Record; - method?: string; - body?: any; - signal?: AbortSignal; -}; +export { HttpInit }; -// @public (undocumented) -export function httpJson(url: string, init?: HttpInit): Promise; +export { httpJson }; -// @public (undocumented) +// @public @deprecated (undocumented) export function refreshAccessToken( instanceName: string, ): Promise; -// @public (undocumented) -export type SecretStore = { - get(service: string, account: string): Promise; - set(service: string, account: string, secret: string): Promise; - delete(service: string, account: string): Promise; -}; +export { SecretStore }; -// @public (undocumented) -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; +export { StoredInstance }; // @public (undocumented) export function updateInstanceConfig( diff --git a/packages/cli-module-auth/src/commands/printToken.ts b/packages/cli-module-auth/src/commands/printToken.ts index 39a78c1832..45b4cfd9f6 100644 --- a/packages/cli-module-auth/src/commands/printToken.ts +++ b/packages/cli-module-auth/src/commands/printToken.ts @@ -15,10 +15,7 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; -import { getSelectedInstance } from '../lib/storage'; -import { getSecretStore } from '../lib/secretStore'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { const { @@ -37,18 +34,8 @@ export default async ({ args, info }: CliCommandContext) => { 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.'); - } + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); process.stdout.write(`${accessToken}\n`); }; diff --git a/packages/cli-module-auth/src/commands/show.ts b/packages/cli-module-auth/src/commands/show.ts index e1b7c62f72..f9a3f4eb90 100644 --- a/packages/cli-module-auth/src/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -15,11 +15,7 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { httpJson } from '../lib/http'; -import { getSelectedInstance } from '../lib/storage'; -import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; -import { getSecretStore } from '../lib/secretStore'; +import { CliAuth, httpJson, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { const { @@ -38,23 +34,13 @@ export default async ({ args, info }: CliCommandContext) => { args, ); - let instance = await getSelectedInstance(instanceFlag); + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); - if (accessTokenNeedsRefresh(instance)) { - process.stdout.write('Refreshing access token...\n'); - instance = await refreshAccessToken(instance.name); - } - const authBase = new URL('/api/auth', instance.baseUrl) + const authBase = new URL('/api/auth', auth.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`, { diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index 38567e2174..a8079932d4 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -53,16 +53,16 @@ export default createCliModule({ }, }); -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export { getSelectedInstance, getInstanceConfig, updateInstanceConfig, type StoredInstance, } from './lib/storage'; -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; -/** @public */ +/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ export { getSecretStore, type SecretStore } from './lib/secretStore'; -/** @public */ +/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ export { httpJson, type HttpInit } from './lib/http'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 36bdf3dfbc..d53aad6cb2 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -15,12 +15,8 @@ */ import { z } from 'zod/v3'; -import { - StoredInstance, - upsertInstance, - withMetadataLock, - getInstanceByName, -} from './storage'; +import type { StoredInstance } from '@backstage/cli-node'; +import { upsertInstance, withMetadataLock, getInstanceByName } from './storage'; import { getSecretStore } from './secretStore'; import { httpJson } from './http'; @@ -31,12 +27,13 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { - return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration + // 2 minutes before expiration + return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function refreshAccessToken( instanceName: string, ): Promise { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 4861a07722..df0704fcf4 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -14,28 +14,4 @@ * limitations under the License. */ -import { ResponseError } from '@backstage/errors'; - -/** @public */ -export type HttpInit = { - headers?: Record; - method?: string; - body?: any; - signal?: AbortSignal; -}; - -/** @public */ -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; -} +export { httpJson, type HttpInit } from '@backstage/cli-node'; diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index a0f3c81d51..f7aa5256ae 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -18,12 +18,8 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -/** @public */ -export type SecretStore = { - get(service: string, account: string): Promise; - set(service: string, account: string, secret: string): Promise; - delete(service: string, account: string): Promise; -}; +export type { SecretStore } from '@backstage/cli-node'; +import type { SecretStore } from '@backstage/cli-node'; async function loadKeytar(): Promise { try { diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index 0e7668a0ea..b50b472157 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -22,6 +22,9 @@ import lockfile from 'proper-lockfile'; import YAML from 'yaml'; import { z } from 'zod/v3'; +export type { StoredInstance } from '@backstage/cli-node'; +import type { StoredInstance } from '@backstage/cli-node'; + const METADATA_FILE = 'auth-instances.yaml'; const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/; @@ -39,17 +42,6 @@ const storedInstanceSchema = z.object({ config: z.record(z.string(), z.unknown()).optional(), }); -/** @public */ -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; - const authYamlSchema = z.object({ instances: z.array(storedInstanceSchema).default([]), }); @@ -99,7 +91,6 @@ export async function getAllInstances(): Promise<{ 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, @@ -108,7 +99,7 @@ export async function getAllInstances(): Promise<{ }; } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -171,7 +162,7 @@ export async function setSelectedInstance(name: string): Promise { }); } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth.getConfig} instead. */ export async function getInstanceConfig( instanceName: string, key: string, diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index fbefa34fcc..4aeab07e7b 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -52,6 +52,9 @@ "@backstage/test-utils": "workspace:^", "@types/yarnpkg__lockfile": "^1.1.4" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "peerDependencies": { "@swc/core": "^1.15.6" }, diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 2fac82080b..5af6665789 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -86,6 +86,21 @@ export interface BackstagePackageJson { version: string; } +// @public +export class CliAuth { + get baseUrl(): string; + static create(options?: CliAuthCreateOptions): Promise; + getAccessToken(): Promise; + getConfig(key: string): Promise; + get instance(): StoredInstance; + get instanceName(): string; +} + +// @public +export interface CliAuthCreateOptions { + instanceName?: string; +} + // @public export interface CliCommand { deprecated?: boolean; @@ -133,6 +148,9 @@ export function createCliModule(options: { }) => Promise; }): CliModule; +// @public (undocumented) +export function getSecretStore(): Promise; + // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -142,6 +160,17 @@ export class GitUtils { // @public export function hasBackstageYarnPlugin(workspaceDir?: string): Promise; +// @public (undocumented) +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +// @public (undocumented) +export function httpJson(url: string, init?: HttpInit): Promise; + // @public export function isMonoRepo(): Promise; @@ -272,6 +301,24 @@ export function runWorkerQueueThreads( results: TResult[]; }>; +// @public (undocumented) +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +// @public (undocumented) +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + // @public export class SuccessCache { // (undocumented) diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts new file mode 100644 index 0000000000..16a9b10dde --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { CliAuth } from './CliAuth'; +import * as storage from './storage'; +import * as secretStoreModule from './secretStore'; +import * as httpModule from './httpJson'; + +jest.mock('./storage'); +jest.mock('./secretStore'); +jest.mock('./httpJson'); + +const mockStorage = storage as jest.Mocked; +const mockSecretStoreModule = secretStoreModule as jest.Mocked< + typeof secretStoreModule +>; +const mockHttp = httpModule as jest.Mocked; + +describe('CliAuth', () => { + const now = Date.now(); + const mockInstance = { + name: 'production', + baseUrl: 'https://backstage.example.com', + clientId: 'prod-client', + issuedAt: now, + accessTokenExpiresAt: now + 3600_000, + }; + + const mockSecretStore = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockStorage.getSelectedInstance.mockResolvedValue(mockInstance); + mockSecretStoreModule.getSecretStore.mockResolvedValue(mockSecretStore); + mockStorage.accessTokenNeedsRefresh.mockReturnValue(false); + mockSecretStore.get.mockResolvedValue('test-access-token'); + }); + + describe('create', () => { + it('resolves the currently selected instance by default', async () => { + const auth = await CliAuth.create(); + + expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith(undefined); + expect(auth.instance).toEqual(mockInstance); + expect(auth.instanceName).toBe('production'); + expect(auth.baseUrl).toBe('https://backstage.example.com'); + }); + + it('resolves a named instance when specified', async () => { + await CliAuth.create({ instanceName: 'staging' }); + + expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith('staging'); + }); + + it('throws when no instance can be found', async () => { + mockStorage.getSelectedInstance.mockRejectedValue( + new Error( + 'No instances found. Run "auth login" to authenticate first.', + ), + ); + + await expect(CliAuth.create()).rejects.toThrow( + 'No instances found. Run "auth login" to authenticate first.', + ); + }); + }); + + describe('getAccessToken', () => { + it('returns a stored access token when it is still valid', async () => { + const auth = await CliAuth.create(); + const token = await auth.getAccessToken(); + + expect(token).toBe('test-access-token'); + expect(mockSecretStore.get).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'accessToken', + ); + expect(mockHttp.httpJson).not.toHaveBeenCalled(); + }); + + it('throws when no access token is stored', async () => { + mockSecretStore.get.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'No access token found. Run "auth login" to authenticate.', + ); + }); + + it('refreshes the token when it is about to expire', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'refreshToken') return 'old-refresh-token'; + if (account === 'accessToken') return 'new-access-token'; + return undefined; + }, + ); + + mockHttp.httpJson.mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }); + + const auth = await CliAuth.create(); + const token = await auth.getAccessToken(); + + expect(token).toBe('new-access-token'); + expect(mockHttp.httpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/auth/v1/token', + expect.objectContaining({ + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token: 'old-refresh-token', + }, + }), + ); + expect(mockSecretStore.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'accessToken', + 'new-access-token', + ); + expect(mockSecretStore.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'refreshToken', + 'new-refresh-token', + ); + }); + + it('throws when refresh token is missing and access token has expired', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'Access token is expired and no refresh token is available', + ); + }); + + it('throws when the token response is malformed', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'refreshToken') return 'refresh-token'; + return undefined; + }, + ); + + mockHttp.httpJson.mockResolvedValue({ + token_type: 'Bearer', + expires_in: 3600, + }); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'Invalid token response', + ); + }); + }); + + describe('getConfig', () => { + it('returns a config value from the instance', async () => { + mockStorage.getInstanceConfig.mockResolvedValue([ + 'catalog', + 'scaffolder', + ]); + + const auth = await CliAuth.create(); + const sources = await auth.getConfig('pluginSources'); + + expect(sources).toEqual(['catalog', 'scaffolder']); + expect(mockStorage.getInstanceConfig).toHaveBeenCalledWith( + 'production', + 'pluginSources', + ); + }); + + it('returns undefined for missing config keys', async () => { + mockStorage.getInstanceConfig.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + const value = await auth.getConfig('nonexistent'); + + expect(value).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts new file mode 100644 index 0000000000..8934e9bd7f --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -0,0 +1,156 @@ +/* + * 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 { + type StoredInstance, + getSelectedInstance, + getInstanceConfig, + accessTokenNeedsRefresh, +} from './storage'; +import { getSecretStore, type SecretStore } from './secretStore'; +import { httpJson } from './httpJson'; +import { z } from 'zod'; + +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(), +}); + +/** + * Options for creating a {@link CliAuth} instance. + * + * @public + */ +export interface CliAuthCreateOptions { + /** + * An explicit instance name to resolve. When omitted the currently + * selected instance is used. + */ + instanceName?: string; +} + +/** + * Manages authentication state for Backstage CLI commands. + * + * Reads the currently selected (or explicitly named) auth instance from + * the on-disk instance store, transparently refreshes expired access + * tokens, and exposes helpers that other CLI modules need to talk to a + * Backstage backend. + * + * @public + */ +export class CliAuth { + readonly #secretStore: SecretStore; + #instance: StoredInstance; + + /** + * Resolve the current auth instance and return a ready-to-use + * {@link CliAuth} object. Throws when no instance can be found. + */ + static async create(options?: CliAuthCreateOptions): Promise { + const instance = await getSelectedInstance(options?.instanceName); + const secretStore = await getSecretStore(); + return new CliAuth(instance, secretStore); + } + + private constructor(instance: StoredInstance, secretStore: SecretStore) { + this.#instance = instance; + this.#secretStore = secretStore; + } + + /** The resolved instance metadata. */ + get instance(): StoredInstance { + return this.#instance; + } + + /** Shorthand for `instance.name`. */ + get instanceName(): string { + return this.#instance.name; + } + + /** Shorthand for `instance.baseUrl`. */ + get baseUrl(): string { + return this.#instance.baseUrl; + } + + /** + * Returns a valid access token, refreshing it first if the current + * token is expired or about to expire. + */ + async getAccessToken(): Promise { + if (accessTokenNeedsRefresh(this.#instance)) { + await this.#refreshAccessToken(); + } + + const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const token = await this.#secretStore.get(service, 'accessToken'); + if (!token) { + throw new Error( + 'No access token found. Run "auth login" to authenticate.', + ); + } + return token; + } + + /** + * Reads a per-instance configuration value previously stored by the + * auth module (e.g. `pluginSources`). + */ + async getConfig(key: string): Promise { + return getInstanceConfig(this.#instance.name, key); + } + + async #refreshAccessToken(): Promise { + const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const refreshToken = + (await this.#secretStore.get(service, 'refreshToken')) ?? ''; + if (!refreshToken) { + throw new Error( + 'Access token is expired and no refresh token is available', + ); + } + + const response = await httpJson( + `${this.#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 this.#secretStore.set(service, 'accessToken', token.access_token); + if (token.refresh_token) { + await this.#secretStore.set(service, 'refreshToken', token.refresh_token); + } + this.#instance = { + ...this.#instance, + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + token.expires_in * 1000, + }; + } +} diff --git a/packages/cli-node/src/auth/httpJson.ts b/packages/cli-node/src/auth/httpJson.ts new file mode 100644 index 0000000000..4861a07722 --- /dev/null +++ b/packages/cli-node/src/auth/httpJson.ts @@ -0,0 +1,41 @@ +/* + * 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 { ResponseError } from '@backstage/errors'; + +/** @public */ +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +/** @public */ +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-node/src/auth/index.ts b/packages/cli-node/src/auth/index.ts new file mode 100644 index 0000000000..8037f25714 --- /dev/null +++ b/packages/cli-node/src/auth/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { CliAuth, type CliAuthCreateOptions } from './CliAuth'; +export { type StoredInstance } from './storage'; +export { httpJson, type HttpInit } from './httpJson'; +export { getSecretStore, type SecretStore } from './secretStore'; diff --git a/packages/cli-node/src/auth/secretStore.ts b/packages/cli-node/src/auth/secretStore.ts new file mode 100644 index 0000000000..a0f3c81d51 --- /dev/null +++ b/packages/cli-node/src/auth/secretStore.ts @@ -0,0 +1,112 @@ +/* + * 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'; + +/** @public */ +export 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; + +/** @public */ +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-node/src/auth/storage.ts b/packages/cli-node/src/auth/storage.ts new file mode 100644 index 0000000000..e223cac45d --- /dev/null +++ b/packages/cli-node/src/auth/storage.ts @@ -0,0 +1,144 @@ +/* + * 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 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(), + config: z.record(z.string(), z.unknown()).optional(), +}); + +/** @public */ +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), +}); + +/** @internal */ +export 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); +} + +/** @internal */ +export 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: [] }; + } +} + +/** @internal */ +export async function getAllInstances(): Promise<{ + instances: StoredInstance[]; + selected: StoredInstance | undefined; +}> { + const { instances } = await readAll(); + const selected = instances.find(i => i.selected) ?? instances[0]; + return { + instances: instances.map(i => ({ + ...i, + selected: i.name === selected.name, + })), + selected, + }; +} + +/** @internal */ +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; +} + +/** @internal */ +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; +} + +/** @internal */ +export async function getInstanceConfig( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + return instance.config?.[key] as T | undefined; +} + +/** @internal */ +export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { + // 2 minutes before expiration + return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; +} diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 8831753a82..a824673c1e 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './auth'; export * from './cache'; export * from './cli-module'; export * from './concurrency'; diff --git a/yarn.lock b/yarn.lock index 4063ff72e8..2a8c8e7f02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3159,12 +3159,16 @@ __metadata: chalk: "npm:^4.0.0" commander: "npm:^12.0.0" fs-extra: "npm:^11.2.0" + keytar: "npm:^7.9.0" pirates: "npm:^4.0.6" semver: "npm:^7.5.3" yaml: "npm:^2.0.0" zod: "npm:^3.25.76 || ^4.0.0" peerDependencies: "@swc/core": ^1.15.6 + dependenciesMeta: + keytar: + optional: true peerDependenciesMeta: "@swc/core": optional: true From 3c6de38345b90201eca05aa4795323b0cdd2e685 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 13:32:46 +0100 Subject: [PATCH 2/8] Update API report for cli-module-auth Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli-module-auth/report.api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 39011f0bac..91191153c8 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -4,7 +4,6 @@ ```ts import { CliModule } from '@backstage/cli-node'; -import { getSecretStore } from '@backstage/cli-node'; import { HttpInit } from '@backstage/cli-node'; import { httpJson } from '@backstage/cli-node'; import { SecretStore } from '@backstage/cli-node'; @@ -23,7 +22,8 @@ export function getInstanceConfig( key: string, ): Promise; -export { getSecretStore }; +// @public (undocumented) +export function getSecretStore(): Promise; // @public @deprecated (undocumented) export function getSelectedInstance( From da8e6603a4319bb5229421dacbb10e2bae55ba4c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 16:26:49 +0100 Subject: [PATCH 3/8] Clean up unreleased API surface Since cli-module-auth and cli-module-actions are not yet released, remove deprecated exports instead of keeping them. Also make httpJson and getSecretStore internal to cli-node, duplicating the small httpJson wrapper locally in each consuming package. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/cli-module-actions-use-cli-auth.md | 5 --- .../cli-module-auth-deprecate-exports.md | 5 --- .changeset/cli-node-auth-api.md | 2 +- packages/cli-module-actions/package.json | 1 + .../src/lib/ActionsClient.test.ts | 12 ++---- .../src/lib/ActionsClient.ts | 2 +- .../cli-module-actions/src/lib/httpJson.ts | 39 +++++++++++++++++++ packages/cli-module-auth/report.api.md | 34 ---------------- packages/cli-module-auth/src/commands/show.ts | 3 +- packages/cli-module-auth/src/index.ts | 14 +------ packages/cli-module-auth/src/lib/auth.ts | 2 - packages/cli-module-auth/src/lib/http.ts | 24 +++++++++++- .../cli-module-auth/src/lib/secretStore.ts | 8 ++-- packages/cli-module-auth/src/lib/storage.ts | 2 - packages/cli-node/report.api.md | 21 ---------- packages/cli-node/src/auth/index.ts | 2 - plugins/app-react/report.api.md | 4 +- plugins/app/report.api.md | 2 +- yarn.lock | 1 + 19 files changed, 81 insertions(+), 102 deletions(-) delete mode 100644 .changeset/cli-module-actions-use-cli-auth.md delete mode 100644 .changeset/cli-module-auth-deprecate-exports.md create mode 100644 packages/cli-module-actions/src/lib/httpJson.ts diff --git a/.changeset/cli-module-actions-use-cli-auth.md b/.changeset/cli-module-actions-use-cli-auth.md deleted file mode 100644 index 705ebac220..0000000000 --- a/.changeset/cli-module-actions-use-cli-auth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli-module-actions': patch ---- - -Migrated to use `CliAuth` from `@backstage/cli-node` for authentication instead of importing individual functions from `@backstage/cli-module-auth`. diff --git a/.changeset/cli-module-auth-deprecate-exports.md b/.changeset/cli-module-auth-deprecate-exports.md deleted file mode 100644 index 5a8c79feef..0000000000 --- a/.changeset/cli-module-auth-deprecate-exports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli-module-auth': patch ---- - -Deprecated `getSelectedInstance`, `getInstanceConfig`, `accessTokenNeedsRefresh`, `refreshAccessToken`, `getSecretStore`, and `httpJson` exports in favor of the new `CliAuth` class and shared utilities from `@backstage/cli-node`. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md index bed9681178..c28a2e29c8 100644 --- a/.changeset/cli-node-auth-api.md +++ b/.changeset/cli-node-auth-api.md @@ -2,4 +2,4 @@ '@backstage/cli-node': minor --- -Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend. Also added `httpJson`, `getSecretStore`, `SecretStore`, `StoredInstance`, and `HttpInit` exports. +Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend. diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index fe19a8a90d..c177e91fc4 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -35,6 +35,7 @@ "dependencies": { "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-node": "workspace:^", + "@backstage/errors": "workspace:^", "cleye": "^2.3.0" }, "devDependencies": { diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts index ecf7861f26..3aa2e06dc7 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.test.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -15,15 +15,11 @@ */ import { ActionsClient } from './ActionsClient'; -import { httpJson } from '@backstage/cli-node'; +import { httpJson } from './httpJson'; -jest.mock('@backstage/cli-node', () => { - const actual = jest.requireActual('@backstage/cli-node'); - return { - ...actual, - httpJson: jest.fn(), - }; -}); +jest.mock('./httpJson', () => ({ + httpJson: jest.fn(), +})); const mockHttpJson = httpJson as jest.MockedFunction; diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts index f7d284ad1a..736a3b7f37 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { httpJson } from '@backstage/cli-node'; +import { httpJson } from './httpJson'; export type ActionDef = { id: string; diff --git a/packages/cli-module-actions/src/lib/httpJson.ts b/packages/cli-module-actions/src/lib/httpJson.ts new file mode 100644 index 0000000000..0bea9dab94 --- /dev/null +++ b/packages/cli-module-actions/src/lib/httpJson.ts @@ -0,0 +1,39 @@ +/* + * 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 { 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-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 91191153c8..67fb636baa 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -4,45 +4,11 @@ ```ts import { CliModule } from '@backstage/cli-node'; -import { HttpInit } from '@backstage/cli-node'; -import { httpJson } from '@backstage/cli-node'; -import { SecretStore } from '@backstage/cli-node'; -import { StoredInstance } from '@backstage/cli-node'; - -// @public @deprecated (undocumented) -export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; // @public (undocumented) const _default: CliModule; export default _default; -// @public @deprecated (undocumented) -export function getInstanceConfig( - instanceName: string, - key: string, -): Promise; - -// @public (undocumented) -export function getSecretStore(): Promise; - -// @public @deprecated (undocumented) -export function getSelectedInstance( - instanceName?: string, -): Promise; - -export { HttpInit }; - -export { httpJson }; - -// @public @deprecated (undocumented) -export function refreshAccessToken( - instanceName: string, -): Promise; - -export { SecretStore }; - -export { StoredInstance }; - // @public (undocumented) export function updateInstanceConfig( instanceName: string, diff --git a/packages/cli-module-auth/src/commands/show.ts b/packages/cli-module-auth/src/commands/show.ts index f9a3f4eb90..6a5db7f299 100644 --- a/packages/cli-module-auth/src/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -15,7 +15,8 @@ */ import { cli } from 'cleye'; -import { CliAuth, httpJson, type CliCommandContext } from '@backstage/cli-node'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { httpJson } from '../lib/http'; export default async ({ args, info }: CliCommandContext) => { const { diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index a8079932d4..6fef1aea2b 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -53,16 +53,4 @@ export default createCliModule({ }, }); -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ -export { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, - type StoredInstance, -} from './lib/storage'; -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ -export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; -/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ -export { getSecretStore, type SecretStore } from './lib/secretStore'; -/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ -export { httpJson, type HttpInit } from './lib/http'; +export { updateInstanceConfig } from './lib/storage'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index d53aad6cb2..4b330cf982 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -27,13 +27,11 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { // 2 minutes before expiration return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; } -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function refreshAccessToken( instanceName: string, ): Promise { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index df0704fcf4..4daaf823fb 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -14,4 +14,26 @@ * limitations under the License. */ -export { httpJson, type HttpInit } from '@backstage/cli-node'; +import { ResponseError } from '@backstage/errors'; + +export 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-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index f7aa5256ae..55fac878b7 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -18,8 +18,11 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -export type { SecretStore } from '@backstage/cli-node'; -import type { SecretStore } from '@backstage/cli-node'; +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 { @@ -86,7 +89,6 @@ class FileSecretStore implements SecretStore { let singleton: SecretStore | undefined; -/** @public */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index b50b472157..d66d4fb04a 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -99,7 +99,6 @@ export async function getAllInstances(): Promise<{ }; } -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -162,7 +161,6 @@ export async function setSelectedInstance(name: string): Promise { }); } -/** @public @deprecated Use {@link @backstage/cli-node#CliAuth.getConfig} instead. */ export async function getInstanceConfig( instanceName: string, key: string, diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 5af6665789..eeeb92f5b3 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -148,9 +148,6 @@ export function createCliModule(options: { }) => Promise; }): CliModule; -// @public (undocumented) -export function getSecretStore(): Promise; - // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -160,17 +157,6 @@ export class GitUtils { // @public export function hasBackstageYarnPlugin(workspaceDir?: string): Promise; -// @public (undocumented) -export type HttpInit = { - headers?: Record; - method?: string; - body?: any; - signal?: AbortSignal; -}; - -// @public (undocumented) -export function httpJson(url: string, init?: HttpInit): Promise; - // @public export function isMonoRepo(): Promise; @@ -301,13 +287,6 @@ export function runWorkerQueueThreads( results: TResult[]; }>; -// @public (undocumented) -export type SecretStore = { - get(service: string, account: string): Promise; - set(service: string, account: string, secret: string): Promise; - delete(service: string, account: string): Promise; -}; - // @public (undocumented) export type StoredInstance = { name: string; diff --git a/packages/cli-node/src/auth/index.ts b/packages/cli-node/src/auth/index.ts index 8037f25714..c84c1742d7 100644 --- a/packages/cli-node/src/auth/index.ts +++ b/packages/cli-node/src/auth/index.ts @@ -16,5 +16,3 @@ export { CliAuth, type CliAuthCreateOptions } from './CliAuth'; export { type StoredInstance } from './storage'; -export { httpJson, type HttpInit } from './httpJson'; -export { getSecretStore, type SecretStore } from './secretStore'; diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index a4e63e254b..4e3fff6e42 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -86,7 +86,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ }; output: ExtensionDataRef< { - [x: string]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} @@ -97,7 +97,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ dataRefs: { icons: ConfigurableExtensionDataRef< { - [x: string]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 4c9b2a8ce4..baa3b714b0 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -478,7 +478,7 @@ const appPlugin: OverridableFrontendPlugin< icons: ExtensionInput< ConfigurableExtensionDataRef< { - [x: string]: IconComponent | IconElement; + [x: string]: IconElement | IconComponent; }, 'core.icons', {} diff --git a/yarn.lock b/yarn.lock index 2a8c8e7f02..3996199a39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2830,6 +2830,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-node": "workspace:^" + "@backstage/errors": "workspace:^" cleye: "npm:^2.3.0" bin: cli-module-actions: bin/backstage-cli-module-actions From 2b9035873027c509e0b65854a3691db430ce4289 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 17:10:01 +0100 Subject: [PATCH 4/8] Address PR review feedback - Convert CliAuth getters to methods (getInstanceName, getBaseUrl) so options can be added in the future - Remove StoredInstance from cli-node public API, hiding instance details - Move secretStore to cli-internal for re-use, refactoring from fs-extra to node:fs - Add shared getAuthInstanceService helper in cli-internal for constructing secret-store service keys - Define StoredInstance locally in cli-module-auth instead of importing from cli-node - Update all consumers and tests for the new method-based API Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli-internal/src/authIdentifiers.ts | 20 +++++++++++ packages/cli-internal/src/index.ts | 6 ++++ .../lib => cli-internal/src}/secretStore.ts | 27 ++++++++++++--- .../src/commands/execute.ts | 4 +-- .../cli-module-actions/src/commands/list.ts | 4 +-- .../src/commands/sourcesAdd.ts | 2 +- .../src/commands/sourcesRemove.ts | 2 +- .../src/lib/resolveAuth.test.ts | 33 +++++++------------ .../cli-module-actions/src/lib/resolveAuth.ts | 12 +++++-- packages/cli-module-auth/package.json | 4 +-- .../cli-module-auth/src/commands/login.ts | 4 +-- .../cli-module-auth/src/commands/logout.ts | 4 +-- packages/cli-module-auth/src/commands/show.ts | 2 +- packages/cli-module-auth/src/lib/auth.test.ts | 11 ++++--- packages/cli-module-auth/src/lib/auth.ts | 12 ++++--- .../src/lib/secretStore.test.ts | 2 +- packages/cli-module-auth/src/lib/storage.ts | 11 +++++-- packages/cli-node/report.api.md | 16 ++------- packages/cli-node/src/auth/CliAuth.test.ts | 5 ++- packages/cli-node/src/auth/CliAuth.ts | 18 ++++------ packages/cli-node/src/auth/authIdentifiers.ts | 20 +++++++++++ packages/cli-node/src/auth/index.ts | 1 - packages/cli-node/src/auth/secretStore.ts | 29 ++++++++++++---- 23 files changed, 160 insertions(+), 89 deletions(-) create mode 100644 packages/cli-internal/src/authIdentifiers.ts rename packages/{cli-module-auth/src/lib => cli-internal/src}/secretStore.ts (87%) create mode 100644 packages/cli-node/src/auth/authIdentifiers.ts diff --git a/packages/cli-internal/src/authIdentifiers.ts b/packages/cli-internal/src/authIdentifiers.ts new file mode 100644 index 0000000000..6b72554a4f --- /dev/null +++ b/packages/cli-internal/src/authIdentifiers.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** Returns the secret-store service key for a given auth instance. */ +export function getAuthInstanceService(instanceName: string): string { + return `backstage-cli:auth-instance:${instanceName}`; +} diff --git a/packages/cli-internal/src/index.ts b/packages/cli-internal/src/index.ts index 0e69d45032..281b998d92 100644 --- a/packages/cli-internal/src/index.ts +++ b/packages/cli-internal/src/index.ts @@ -25,3 +25,9 @@ export { OpaqueCommandLeafNode, isCommandNodeHidden, } from './InternalCommandNode'; +export { getAuthInstanceService } from './authIdentifiers'; +export { + getSecretStore, + resetSecretStore, + type SecretStore, +} from './secretStore'; diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-internal/src/secretStore.ts similarity index 87% rename from packages/cli-module-auth/src/lib/secretStore.ts rename to packages/cli-internal/src/secretStore.ts index 55fac878b7..26c1e6ad2b 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-internal/src/secretStore.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import fs from 'fs-extra'; +import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -type SecretStore = { +export type SecretStore = { get(service: string, account: string): Promise; set(service: string, account: string, secret: string): Promise; delete(service: string, account: string): Promise; @@ -54,6 +54,15 @@ class KeytarSecretStore implements SecretStore { } } +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + class FileSecretStore implements SecretStore { private readonly baseDir: string; constructor() { @@ -73,17 +82,25 @@ class FileSecretStore implements SecretStore { } async get(service: string, account: string): Promise { const file = this.filePath(service, account); - if (!(await fs.pathExists(file))) return undefined; + if (!(await 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.mkdir(path.dirname(file), { recursive: true }); 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); + try { + await fs.unlink(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } } } diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts index 4ff955096b..f130c597ce 100644 --- a/packages/cli-module-actions/src/commands/execute.ts +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -65,9 +65,9 @@ export default async ({ args, info }: CliCommandContext) => { process.exit(1); } - const { accessToken, instance } = await resolveAuth(instanceFlag); + const { accessToken, baseUrl } = await resolveAuth(instanceFlag); - const client = new ActionsClient(instance.baseUrl, accessToken); + const client = new ActionsClient(baseUrl, accessToken); const actions = await client.listForPlugin(actionId); const action = actions.find(a => a.id === actionId); diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts index 601fa6d2c7..2049fc3e44 100644 --- a/packages/cli-module-actions/src/commands/list.ts +++ b/packages/cli-module-actions/src/commands/list.ts @@ -36,7 +36,7 @@ export default async ({ args, info }: CliCommandContext) => { args, ); - const { accessToken, pluginSources, instance } = await resolveAuth( + const { accessToken, pluginSources, baseUrl } = await resolveAuth( instanceFlag, ); @@ -47,7 +47,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - const client = new ActionsClient(instance.baseUrl, accessToken); + const client = new ActionsClient(baseUrl, accessToken); const actions = await client.list(pluginSources); if (!actions.length) { diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index 6d9ea2b524..0fc4addd5c 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -40,7 +40,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(auth.instanceName, 'pluginSources', [ + await updateInstanceConfig(auth.getInstanceName(), 'pluginSources', [ ...existing, pluginId, ]); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts index 81a2d5abf3..b680c39684 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -39,7 +39,7 @@ export default async ({ args, info }: CliCommandContext) => { } await updateInstanceConfig( - auth.instanceName, + auth.getInstanceName(), 'pluginSources', existing.filter(s => s !== pluginId), ); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index 21cf6ebc38..21f494c54a 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -15,7 +15,7 @@ */ import { resolveAuth } from './resolveAuth'; -import { CliAuth, type StoredInstance } from '@backstage/cli-node'; +import { CliAuth } from '@backstage/cli-node'; jest.mock('@backstage/cli-node', () => { const actual = jest.requireActual('@backstage/cli-node'); @@ -28,23 +28,14 @@ jest.mock('@backstage/cli-node', () => { const mockCreate = CliAuth.create as jest.MockedFunction; describe('resolveAuth', () => { - const mockInstance: StoredInstance = { - name: 'production', - baseUrl: 'https://backstage.example.com', - clientId: 'my-client', - issuedAt: Date.now(), - accessTokenExpiresAt: Date.now() + 3600_000, - }; - beforeEach(() => { jest.clearAllMocks(); }); it('resolves auth with the selected instance and stored token', async () => { mockCreate.mockResolvedValue({ - instance: mockInstance, - instanceName: mockInstance.name, - baseUrl: mockInstance.baseUrl, + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), getConfig: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), } as unknown as CliAuth); @@ -53,7 +44,8 @@ describe('resolveAuth', () => { expect(mockCreate).toHaveBeenCalledWith({ instanceName: undefined }); expect(result).toEqual({ - instance: mockInstance, + baseUrl: 'https://backstage.example.com', + instanceName: 'production', accessToken: 'test-access-token', pluginSources: ['catalog', 'scaffolder'], }); @@ -61,9 +53,8 @@ describe('resolveAuth', () => { it('passes instance name flag to CliAuth.create', async () => { mockCreate.mockResolvedValue({ - instance: mockInstance, - instanceName: mockInstance.name, - baseUrl: mockInstance.baseUrl, + getInstanceName: jest.fn().mockReturnValue('staging'), + getBaseUrl: jest.fn().mockReturnValue('https://staging.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), getConfig: jest.fn().mockResolvedValue([]), } as unknown as CliAuth); @@ -75,9 +66,8 @@ describe('resolveAuth', () => { it('throws when getAccessToken fails', async () => { mockCreate.mockResolvedValue({ - instance: mockInstance, - instanceName: mockInstance.name, - baseUrl: mockInstance.baseUrl, + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest .fn() .mockRejectedValue( @@ -93,9 +83,8 @@ describe('resolveAuth', () => { it('returns empty plugin sources when none are configured', async () => { mockCreate.mockResolvedValue({ - instance: mockInstance, - instanceName: mockInstance.name, - baseUrl: mockInstance.baseUrl, + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), getConfig: jest.fn().mockResolvedValue(undefined), } as unknown as CliAuth); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts index 18664b1b55..4d2afe6695 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { CliAuth, type StoredInstance } from '@backstage/cli-node'; +import { CliAuth } from '@backstage/cli-node'; export async function resolveAuth(instanceFlag?: string): Promise<{ - instance: StoredInstance; + baseUrl: string; + instanceName: string; accessToken: string; pluginSources: string[]; }> { @@ -25,5 +26,10 @@ export async function resolveAuth(instanceFlag?: string): Promise<{ const accessToken = await auth.getAccessToken(); const pluginSources = (await auth.getConfig('pluginSources')) ?? []; - return { instance: auth.instance, accessToken, pluginSources }; + return { + baseUrl: auth.getBaseUrl(), + instanceName: auth.getInstanceName(), + accessToken, + pluginSources, + }; } diff --git a/packages/cli-module-auth/package.json b/packages/cli-module-auth/package.json index 94169e998e..b0e2564661 100644 --- a/packages/cli-module-auth/package.json +++ b/packages/cli-module-auth/package.json @@ -19,6 +19,7 @@ "license": "Apache-2.0", "main": "src/index.ts", "types": "src/index.ts", + "bin": "bin/backstage-cli-module-auth", "files": [ "dist", "bin" @@ -50,6 +51,5 @@ }, "optionalDependencies": { "keytar": "^7.9.0" - }, - "bin": "bin/backstage-cli-module-auth" + } } diff --git a/packages/cli-module-auth/src/commands/login.ts b/packages/cli-module-auth/src/commands/login.ts index b16060f956..6bb8751147 100644 --- a/packages/cli-module-auth/src/commands/login.ts +++ b/packages/cli-module-auth/src/commands/login.ts @@ -27,7 +27,7 @@ import { getInstanceByName, StoredInstance, } from '../lib/storage'; -import { getSecretStore } from '../lib/secretStore'; +import { getSecretStore, getAuthInstanceService } from '@internal/cli'; import crypto from 'node:crypto'; import fs from 'fs-extra'; import path from 'node:path'; @@ -321,7 +321,7 @@ async function persistInstance(options: { const { instanceName, backendBaseUrl, clientId, token } = options; const secretStore = await getSecretStore(); await withMetadataLock(async () => { - const service = `backstage-cli:auth-instance:${instanceName}`; + const service = getAuthInstanceService(instanceName); await secretStore.set(service, 'accessToken', token.access_token); if (token.refresh_token) { await secretStore.set(service, 'refreshToken', token.refresh_token); diff --git a/packages/cli-module-auth/src/commands/logout.ts b/packages/cli-module-auth/src/commands/logout.ts index f79ed2ef35..a02fb3580d 100644 --- a/packages/cli-module-auth/src/commands/logout.ts +++ b/packages/cli-module-auth/src/commands/logout.ts @@ -16,7 +16,7 @@ import { cli } from 'cleye'; import type { CliCommandContext } from '@backstage/cli-node'; -import { getSecretStore } from '../lib/secretStore'; +import { getSecretStore, getAuthInstanceService } from '@internal/cli'; import { removeInstance, withMetadataLock, @@ -47,7 +47,7 @@ export default async ({ args, info }: CliCommandContext) => { await withMetadataLock(async () => { const instance = await getInstanceByName(instanceName); const secretStore = await getSecretStore(); - const service = `backstage-cli:auth-instance:${instanceName}`; + const service = getAuthInstanceService(instanceName); const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? ''; if (refreshToken) { diff --git a/packages/cli-module-auth/src/commands/show.ts b/packages/cli-module-auth/src/commands/show.ts index 6a5db7f299..275b6ca66d 100644 --- a/packages/cli-module-auth/src/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -38,7 +38,7 @@ export default async ({ args, info }: CliCommandContext) => { const auth = await CliAuth.create({ instanceName: instanceFlag }); const accessToken = await auth.getAccessToken(); - const authBase = new URL('/api/auth', auth.baseUrl) + const authBase = new URL('/api/auth', auth.getBaseUrl()) .toString() .replace(/\/$/, ''); diff --git a/packages/cli-module-auth/src/lib/auth.test.ts b/packages/cli-module-auth/src/lib/auth.test.ts index 5ba4ae0295..18e7658e92 100644 --- a/packages/cli-module-auth/src/lib/auth.test.ts +++ b/packages/cli-module-auth/src/lib/auth.test.ts @@ -16,15 +16,15 @@ import { accessTokenNeedsRefresh, refreshAccessToken } from './auth'; import * as storage from './storage'; -import * as secretStore from './secretStore'; +import * as internalCli from '@internal/cli'; import * as http from './http'; jest.mock('./storage'); -jest.mock('./secretStore'); +jest.mock('@internal/cli'); jest.mock('./http'); const mockStorage = storage as jest.Mocked; -const mockSecretStore = secretStore as jest.Mocked; +const mockInternalCli = internalCli as jest.Mocked; const mockHttp = http as jest.Mocked; describe('auth', () => { @@ -95,7 +95,10 @@ describe('auth', () => { beforeEach(() => { jest.clearAllMocks(); - mockSecretStore.getSecretStore.mockResolvedValue(mockSecretStoreInstance); + mockInternalCli.getSecretStore.mockResolvedValue(mockSecretStoreInstance); + mockInternalCli.getAuthInstanceService.mockImplementation( + (name: string) => `backstage-cli:auth-instance:${name}`, + ); }); it('should successfully refresh access token', async () => { diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 4b330cf982..1ad108cbf1 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -15,9 +15,13 @@ */ import { z } from 'zod/v3'; -import type { StoredInstance } from '@backstage/cli-node'; -import { upsertInstance, withMetadataLock, getInstanceByName } from './storage'; -import { getSecretStore } from './secretStore'; +import { + type StoredInstance, + upsertInstance, + withMetadataLock, + getInstanceByName, +} from './storage'; +import { getSecretStore, getAuthInstanceService } from '@internal/cli'; import { httpJson } from './http'; const TokenResponseSchema = z.object({ @@ -40,7 +44,7 @@ export async function refreshAccessToken( return withMetadataLock(async () => { const instance = await getInstanceByName(instanceName); - const service = `backstage-cli:auth-instance:${instanceName}`; + const service = getAuthInstanceService(instanceName); const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? ''; if (!refreshToken) { throw new Error( diff --git a/packages/cli-module-auth/src/lib/secretStore.test.ts b/packages/cli-module-auth/src/lib/secretStore.test.ts index 6729f7c6a5..f27d364c7c 100644 --- a/packages/cli-module-auth/src/lib/secretStore.test.ts +++ b/packages/cli-module-auth/src/lib/secretStore.test.ts @@ -21,7 +21,7 @@ jest.mock('keytar', () => { import fs from 'fs-extra'; import path from 'node:path'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { getSecretStore, resetSecretStore } from './secretStore'; +import { getSecretStore, resetSecretStore } from '@internal/cli'; const mockDir = createMockDirectory(); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index d66d4fb04a..32c6178696 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -22,8 +22,15 @@ import lockfile from 'proper-lockfile'; import YAML from 'yaml'; import { z } from 'zod/v3'; -export type { StoredInstance } from '@backstage/cli-node'; -import type { StoredInstance } from '@backstage/cli-node'; +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; const METADATA_FILE = 'auth-instances.yaml'; diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index eeeb92f5b3..ca10eafff2 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -88,12 +88,11 @@ export interface BackstagePackageJson { // @public export class CliAuth { - get baseUrl(): string; static create(options?: CliAuthCreateOptions): Promise; getAccessToken(): Promise; + getBaseUrl(): string; getConfig(key: string): Promise; - get instance(): StoredInstance; - get instanceName(): string; + getInstanceName(): string; } // @public @@ -287,17 +286,6 @@ export function runWorkerQueueThreads( results: TResult[]; }>; -// @public (undocumented) -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; - // @public export class SuccessCache { // (undocumented) diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts index 16a9b10dde..3d932e5e34 100644 --- a/packages/cli-node/src/auth/CliAuth.test.ts +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -58,9 +58,8 @@ describe('CliAuth', () => { const auth = await CliAuth.create(); expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith(undefined); - expect(auth.instance).toEqual(mockInstance); - expect(auth.instanceName).toBe('production'); - expect(auth.baseUrl).toBe('https://backstage.example.com'); + expect(auth.getInstanceName()).toBe('production'); + expect(auth.getBaseUrl()).toBe('https://backstage.example.com'); }); it('resolves a named instance when specified', async () => { diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts index 8934e9bd7f..d7eaad6d40 100644 --- a/packages/cli-node/src/auth/CliAuth.ts +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -21,6 +21,7 @@ import { accessTokenNeedsRefresh, } from './storage'; import { getSecretStore, type SecretStore } from './secretStore'; +import { getAuthInstanceService } from './authIdentifiers'; import { httpJson } from './httpJson'; import { z } from 'zod'; @@ -73,18 +74,13 @@ export class CliAuth { this.#secretStore = secretStore; } - /** The resolved instance metadata. */ - get instance(): StoredInstance { - return this.#instance; - } - - /** Shorthand for `instance.name`. */ - get instanceName(): string { + /** Returns the name of the resolved auth instance. */ + getInstanceName(): string { return this.#instance.name; } - /** Shorthand for `instance.baseUrl`. */ - get baseUrl(): string { + /** Returns the base URL of the resolved auth instance. */ + getBaseUrl(): string { return this.#instance.baseUrl; } @@ -97,7 +93,7 @@ export class CliAuth { await this.#refreshAccessToken(); } - const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const service = getAuthInstanceService(this.#instance.name); const token = await this.#secretStore.get(service, 'accessToken'); if (!token) { throw new Error( @@ -116,7 +112,7 @@ export class CliAuth { } async #refreshAccessToken(): Promise { - const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const service = getAuthInstanceService(this.#instance.name); const refreshToken = (await this.#secretStore.get(service, 'refreshToken')) ?? ''; if (!refreshToken) { diff --git a/packages/cli-node/src/auth/authIdentifiers.ts b/packages/cli-node/src/auth/authIdentifiers.ts new file mode 100644 index 0000000000..16ddb5b437 --- /dev/null +++ b/packages/cli-node/src/auth/authIdentifiers.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** @internal */ +export function getAuthInstanceService(instanceName: string): string { + return `backstage-cli:auth-instance:${instanceName}`; +} diff --git a/packages/cli-node/src/auth/index.ts b/packages/cli-node/src/auth/index.ts index c84c1742d7..d6a1b08d65 100644 --- a/packages/cli-node/src/auth/index.ts +++ b/packages/cli-node/src/auth/index.ts @@ -15,4 +15,3 @@ */ export { CliAuth, type CliAuthCreateOptions } from './CliAuth'; -export { type StoredInstance } from './storage'; diff --git a/packages/cli-node/src/auth/secretStore.ts b/packages/cli-node/src/auth/secretStore.ts index a0f3c81d51..70ef5f2f72 100644 --- a/packages/cli-node/src/auth/secretStore.ts +++ b/packages/cli-node/src/auth/secretStore.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import fs from 'fs-extra'; +import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -/** @public */ +/** @internal */ export type SecretStore = { get(service: string, account: string): Promise; set(service: string, account: string, secret: string): Promise; @@ -55,6 +55,15 @@ class KeytarSecretStore implements SecretStore { } } +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + class FileSecretStore implements SecretStore { private readonly baseDir: string; constructor() { @@ -74,23 +83,31 @@ class FileSecretStore implements SecretStore { } async get(service: string, account: string): Promise { const file = this.filePath(service, account); - if (!(await fs.pathExists(file))) return undefined; + if (!(await 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.mkdir(path.dirname(file), { recursive: true }); 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); + try { + await fs.unlink(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } } } let singleton: SecretStore | undefined; -/** @public */ +/** @internal */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); From 4f6e7de1335cb53df6dc18441e3d02cf4e3b27ae Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 19:24:26 +0100 Subject: [PATCH 5/8] Address second round of PR review feedback - Replace getConfig with getMetadata/setMetadata on CliAuth, removing the unsafe type parameter in favor of returning unknown - Move updateInstanceConfig from cli-module-auth public API to CliAuth.setMetadata, removing the cross-package dependency - Rename 'config' to 'metadata' in StoredInstance and storage schemas - Add zod validation at consumer sites (cli-module-actions) for type-safe metadata access - Fix zod imports to use zod/v3 for compatibility with zod v4 - Add proper-lockfile to cli-node for metadata write locking - Refactor cli-node storage from fs-extra to node:fs - Remove @backstage/cli-module-auth dependency from cli-module-actions Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/auth-module-exports.md | 2 +- .changeset/cli-node-auth-api.md | 2 +- packages/cli-module-actions/package.json | 4 +- .../src/commands/sourcesAdd.ts | 13 ++-- .../src/commands/sourcesList.ts | 7 +- .../src/commands/sourcesRemove.ts | 11 ++- .../src/lib/resolveAuth.test.ts | 8 +- .../cli-module-actions/src/lib/resolveAuth.ts | 7 +- packages/cli-module-auth/report.api.md | 7 -- packages/cli-module-auth/src/index.ts | 2 - .../cli-module-auth/src/lib/storage.test.ts | 44 +++++------ packages/cli-module-auth/src/lib/storage.ts | 15 ++-- packages/cli-node/package.json | 2 + packages/cli-node/report.api.md | 3 +- packages/cli-node/src/auth/CliAuth.test.ts | 29 +++++-- packages/cli-node/src/auth/CliAuth.ts | 18 +++-- packages/cli-node/src/auth/storage.ts | 76 ++++++++++++++++--- yarn.lock | 4 +- 18 files changed, 168 insertions(+), 86 deletions(-) diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md index 0b6dc46ac3..358f12cf61 100644 --- a/.changeset/auth-module-exports.md +++ b/.changeset/auth-module-exports.md @@ -2,4 +2,4 @@ '@backstage/cli-module-auth': patch --- -Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. +Export auth helper utilities for use by other CLI modules. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md index c28a2e29c8..b4b943af5a 100644 --- a/.changeset/cli-node-auth-api.md +++ b/.changeset/cli-node-auth-api.md @@ -1,5 +1,5 @@ --- -'@backstage/cli-node': minor +'@backstage/cli-node': patch --- Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend. diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index c177e91fc4..86c9997322 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -33,10 +33,10 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", - "cleye": "^2.3.0" + "cleye": "^2.3.0", + "zod": "^3.25.76 || ^4.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index 0fc4addd5c..fee3007637 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -16,7 +16,9 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; -import { updateInstanceConfig } from '@backstage/cli-module-auth'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -31,7 +33,9 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; const auth = await CliAuth.create(); - const existing = (await auth.getConfig('pluginSources')) ?? []; + const existing = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (existing.includes(pluginId)) { process.stderr.write( @@ -40,10 +44,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(auth.getInstanceName(), 'pluginSources', [ - ...existing, - pluginId, - ]); + await auth.setMetadata('pluginSources', [...existing, pluginId]); process.stdout.write(`Added plugin source "${pluginId}".\n`); }; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts index 23a6a6449f..7557922c06 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -16,12 +16,17 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { cli({ help: info }, undefined, args); const auth = await CliAuth.create(); - const sources = (await auth.getConfig('pluginSources')) ?? []; + const sources = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (!sources.length) { process.stderr.write('No plugin sources configured.\n'); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts index b680c39684..9225228503 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -16,7 +16,9 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; -import { updateInstanceConfig } from '@backstage/cli-module-auth'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -31,15 +33,16 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; const auth = await CliAuth.create(); - const existing = (await auth.getConfig('pluginSources')) ?? []; + const existing = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (!existing.includes(pluginId)) { process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); return; } - await updateInstanceConfig( - auth.getInstanceName(), + await auth.setMetadata( 'pluginSources', existing.filter(s => s !== pluginId), ); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index 21f494c54a..2aabefe9ea 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -37,7 +37,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('production'), getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), + getMetadata: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), } as unknown as CliAuth); const result = await resolveAuth(); @@ -56,7 +56,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('staging'), getBaseUrl: jest.fn().mockReturnValue('https://staging.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue([]), + getMetadata: jest.fn().mockResolvedValue([]), } as unknown as CliAuth); await resolveAuth('staging'); @@ -73,7 +73,7 @@ describe('resolveAuth', () => { .mockRejectedValue( new Error('No access token found. Run "auth login" to authenticate.'), ), - getConfig: jest.fn().mockResolvedValue([]), + getMetadata: jest.fn().mockResolvedValue([]), } as unknown as CliAuth); await expect(resolveAuth()).rejects.toThrow( @@ -86,7 +86,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('production'), getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue(undefined), + getMetadata: jest.fn().mockResolvedValue(undefined), } as unknown as CliAuth); const result = await resolveAuth(); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts index 4d2afe6695..568bd4cdcd 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -15,6 +15,9 @@ */ import { CliAuth } from '@backstage/cli-node'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export async function resolveAuth(instanceFlag?: string): Promise<{ baseUrl: string; @@ -24,7 +27,9 @@ export async function resolveAuth(instanceFlag?: string): Promise<{ }> { const auth = await CliAuth.create({ instanceName: instanceFlag }); const accessToken = await auth.getAccessToken(); - const pluginSources = (await auth.getConfig('pluginSources')) ?? []; + const pluginSources = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); return { baseUrl: auth.getBaseUrl(), diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 67fb636baa..510bcaabe7 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -9,12 +9,5 @@ import { CliModule } from '@backstage/cli-node'; const _default: CliModule; export default _default; -// @public (undocumented) -export function updateInstanceConfig( - instanceName: string, - key: string, - value: unknown, -): Promise; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index 6fef1aea2b..fef74ec8a5 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -52,5 +52,3 @@ export default createCliModule({ }); }, }); - -export { updateInstanceConfig } from './lib/storage'; diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts index 9264c6bb89..042b0eb72a 100644 --- a/packages/cli-module-auth/src/lib/storage.test.ts +++ b/packages/cli-module-auth/src/lib/storage.test.ts @@ -22,8 +22,8 @@ import { getAllInstances, getSelectedInstance, getInstanceByName, - getInstanceConfig, - updateInstanceConfig, + getInstanceMetadata, + updateInstanceMetadata, upsertInstance, removeInstance, setSelectedInstance, @@ -359,65 +359,65 @@ describe('storage', () => { }); }); - describe('getInstanceConfig', () => { - it('should return undefined when no config set', async () => { + describe('getInstanceMetadata', () => { + it('should return undefined when no metadata set', async () => { await upsertInstance(mockInstance1); - const result = await getInstanceConfig('production', 'someKey'); + const result = await getInstanceMetadata('production', 'someKey'); expect(result).toBeUndefined(); }); - it('should return config value for a key', async () => { + it('should return metadata value for a key', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'myKey', 'myValue'); + await updateInstanceMetadata('production', 'myKey', 'myValue'); - const result = await getInstanceConfig('production', 'myKey'); + const result = await getInstanceMetadata('production', 'myKey'); expect(result).toBe('myValue'); }); it('should throw NotFoundError for unknown instance', async () => { - await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow( + await expect(getInstanceMetadata('nonexistent', 'key')).rejects.toThrow( NotFoundError, ); }); }); - describe('updateInstanceConfig', () => { - it('should set a config value', async () => { + describe('updateInstanceMetadata', () => { + it('should set a metadata value', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key1', 'value1'); - const result = await getInstanceConfig('production', 'key1'); + const result = await getInstanceMetadata('production', 'key1'); expect(result).toBe('value1'); }); - it('should preserve existing config keys', async () => { + it('should preserve existing metadata keys', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); - await updateInstanceConfig('production', 'key2', 'value2'); + await updateInstanceMetadata('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key2', 'value2'); - const result1 = await getInstanceConfig('production', 'key1'); - const result2 = await getInstanceConfig('production', 'key2'); + const result1 = await getInstanceMetadata('production', 'key1'); + const result2 = await getInstanceMetadata('production', 'key2'); expect(result1).toBe('value1'); expect(result2).toBe('value2'); }); it('should throw NotFoundError for unknown instance', async () => { await expect( - updateInstanceConfig('nonexistent', 'key', 'value'), + updateInstanceMetadata('nonexistent', 'key', 'value'), ).rejects.toThrow(NotFoundError); }); - it('should remove instance along with its config', async () => { + it('should remove instance along with its metadata', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key1', 'value1'); await removeInstance('production'); const { instances } = await getAllInstances(); expect(instances.find(i => i.name === 'production')).toBeUndefined(); await upsertInstance(mockInstance1); - const result = await getInstanceConfig('production', 'key1'); + const result = await getInstanceMetadata('production', 'key1'); expect(result).toBeUndefined(); }); }); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index 32c6178696..07e48271a8 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -29,7 +29,7 @@ export type StoredInstance = { issuedAt: number; accessTokenExpiresAt: number; selected?: boolean; - config?: Record; + metadata?: Record; }; const METADATA_FILE = 'auth-instances.yaml'; @@ -46,7 +46,7 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), - config: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }); const authYamlSchema = z.object({ @@ -168,16 +168,15 @@ export async function setSelectedInstance(name: string): Promise { }); } -export async function getInstanceConfig( +export async function getInstanceMetadata( instanceName: string, key: string, -): Promise { +): Promise { const instance = await getInstanceByName(instanceName); - return instance.config?.[key] as T | undefined; + return instance.metadata?.[key]; } -/** @public */ -export async function updateInstanceConfig( +export async function updateInstanceMetadata( instanceName: string, key: string, value: unknown, @@ -190,7 +189,7 @@ export async function updateInstanceConfig( } data.instances[idx] = { ...data.instances[idx], - config: { ...data.instances[idx].config, [key]: value }, + metadata: { ...data.instances[idx].metadata, [key]: value }, }; await writeAll(data); }); diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 4aeab07e7b..9c997c02aa 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -42,6 +42,7 @@ "commander": "^12.0.0", "fs-extra": "^11.2.0", "pirates": "^4.0.6", + "proper-lockfile": "^4.1.2", "semver": "^7.5.3", "yaml": "^2.0.0", "zod": "^3.25.76 || ^4.0.0" @@ -50,6 +51,7 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", + "@types/proper-lockfile": "^4", "@types/yarnpkg__lockfile": "^1.1.4" }, "optionalDependencies": { diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index ca10eafff2..9f04b80ddf 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -91,8 +91,9 @@ export class CliAuth { static create(options?: CliAuthCreateOptions): Promise; getAccessToken(): Promise; getBaseUrl(): string; - getConfig(key: string): Promise; getInstanceName(): string; + getMetadata(key: string): Promise; + setMetadata(key: string, value: unknown): Promise; } // @public diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts index 3d932e5e34..65aa340d33 100644 --- a/packages/cli-node/src/auth/CliAuth.test.ts +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -180,30 +180,43 @@ describe('CliAuth', () => { }); }); - describe('getConfig', () => { - it('returns a config value from the instance', async () => { - mockStorage.getInstanceConfig.mockResolvedValue([ + describe('getMetadata / setMetadata', () => { + it('returns a metadata value from the instance', async () => { + mockStorage.getInstanceMetadata.mockResolvedValue([ 'catalog', 'scaffolder', ]); const auth = await CliAuth.create(); - const sources = await auth.getConfig('pluginSources'); + const sources = await auth.getMetadata('pluginSources'); expect(sources).toEqual(['catalog', 'scaffolder']); - expect(mockStorage.getInstanceConfig).toHaveBeenCalledWith( + expect(mockStorage.getInstanceMetadata).toHaveBeenCalledWith( 'production', 'pluginSources', ); }); - it('returns undefined for missing config keys', async () => { - mockStorage.getInstanceConfig.mockResolvedValue(undefined); + it('returns undefined for missing metadata keys', async () => { + mockStorage.getInstanceMetadata.mockResolvedValue(undefined); const auth = await CliAuth.create(); - const value = await auth.getConfig('nonexistent'); + const value = await auth.getMetadata('nonexistent'); expect(value).toBeUndefined(); }); + + it('writes a metadata value to the instance store', async () => { + mockStorage.updateInstanceMetadata.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + await auth.setMetadata('pluginSources', ['catalog']); + + expect(mockStorage.updateInstanceMetadata).toHaveBeenCalledWith( + 'production', + 'pluginSources', + ['catalog'], + ); + }); }); }); diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts index d7eaad6d40..d65fb9bbfa 100644 --- a/packages/cli-node/src/auth/CliAuth.ts +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -17,13 +17,14 @@ import { type StoredInstance, getSelectedInstance, - getInstanceConfig, + getInstanceMetadata, + updateInstanceMetadata, accessTokenNeedsRefresh, } from './storage'; import { getSecretStore, type SecretStore } from './secretStore'; import { getAuthInstanceService } from './authIdentifiers'; import { httpJson } from './httpJson'; -import { z } from 'zod'; +import { z } from 'zod/v3'; const TokenResponseSchema = z.object({ access_token: z.string().min(1), @@ -104,11 +105,18 @@ export class CliAuth { } /** - * Reads a per-instance configuration value previously stored by the + * Reads a per-instance metadata value previously stored by the * auth module (e.g. `pluginSources`). */ - async getConfig(key: string): Promise { - return getInstanceConfig(this.#instance.name, key); + async getMetadata(key: string): Promise { + return getInstanceMetadata(this.#instance.name, key); + } + + /** + * Writes a per-instance metadata value to the on-disk instance store. + */ + async setMetadata(key: string, value: unknown): Promise { + return updateInstanceMetadata(this.#instance.name, key, value); } async #refreshAccessToken(): Promise { diff --git a/packages/cli-node/src/auth/storage.ts b/packages/cli-node/src/auth/storage.ts index e223cac45d..13a866d058 100644 --- a/packages/cli-node/src/auth/storage.ts +++ b/packages/cli-node/src/auth/storage.ts @@ -15,11 +15,12 @@ */ import { NotFoundError } from '@backstage/errors'; -import fs from 'fs-extra'; +import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import lockfile from 'proper-lockfile'; import YAML from 'yaml'; -import { z } from 'zod'; +import { z } from 'zod/v3'; const METADATA_FILE = 'auth-instances.yaml'; @@ -35,10 +36,13 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), - config: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), }); -/** @public */ export type StoredInstance = { name: string; baseUrl: string; @@ -46,12 +50,17 @@ export type StoredInstance = { issuedAt: number; accessTokenExpiresAt: number; selected?: boolean; - config?: Record; + metadata?: Record; }; -const authYamlSchema = z.object({ - instances: z.array(storedInstanceSchema).default([]), -}); +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} /** @internal */ export function getMetadataFilePath(): string { @@ -67,7 +76,7 @@ export function getMetadataFilePath(): string { /** @internal */ export async function readAll(): Promise<{ instances: StoredInstance[] }> { const file = getMetadataFilePath(); - if (!(await fs.pathExists(file))) { + if (!(await pathExists(file))) { return { instances: [] }; } const text = await fs.readFile(file, 'utf8'); @@ -86,6 +95,29 @@ export async function readAll(): Promise<{ instances: StoredInstance[] }> { } } +async function writeAll(data: { instances: StoredInstance[] }): Promise { + const file = getMetadataFilePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false }); + await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 }); +} + +async function withMetadataLock(fn: () => Promise): Promise { + const file = getMetadataFilePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + if (!(await 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(); + } +} + /** @internal */ export async function getAllInstances(): Promise<{ instances: StoredInstance[]; @@ -129,12 +161,32 @@ export async function getInstanceByName(name: string): Promise { } /** @internal */ -export async function getInstanceConfig( +export async function getInstanceMetadata( instanceName: string, key: string, -): Promise { +): Promise { const instance = await getInstanceByName(instanceName); - return instance.config?.[key] as T | undefined; + return instance.metadata?.[key]; +} + +/** @internal */ +export async function updateInstanceMetadata( + instanceName: string, + key: string, + value: unknown, +): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instanceName); + if (idx === -1) { + throw new NotFoundError(`Instance '${instanceName}' not found`); + } + data.instances[idx] = { + ...data.instances[idx], + metadata: { ...data.instances[idx].metadata, [key]: value }, + }; + await writeAll(data); + }); } /** @internal */ diff --git a/yarn.lock b/yarn.lock index 3996199a39..299ee9995b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2828,10 +2828,10 @@ __metadata: dependencies: "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" - "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-node": "workspace:^" "@backstage/errors": "workspace:^" cleye: "npm:^2.3.0" + zod: "npm:^3.25.76 || ^4.0.0" bin: cli-module-actions: bin/backstage-cli-module-actions languageName: unknown @@ -3154,6 +3154,7 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" + "@types/proper-lockfile": "npm:^4" "@types/yarnpkg__lockfile": "npm:^1.1.4" "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" @@ -3162,6 +3163,7 @@ __metadata: fs-extra: "npm:^11.2.0" keytar: "npm:^7.9.0" pirates: "npm:^4.0.6" + proper-lockfile: "npm:^4.1.2" semver: "npm:^7.5.3" yaml: "npm:^2.0.0" zod: "npm:^3.25.76 || ^4.0.0" From 6cc77e2b5013cca3faf94caa9e79e2aa345764a3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 20:14:53 +0100 Subject: [PATCH 6/8] Drop unreleased cli-module-auth changeset The @backstage/cli-module-auth package is not yet released, so there is no need for a changeset tracking its exports. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/auth-module-exports.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/auth-module-exports.md diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md deleted file mode 100644 index 358f12cf61..0000000000 --- a/.changeset/auth-module-exports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli-module-auth': patch ---- - -Export auth helper utilities for use by other CLI modules. From 4d25b4b784bf3720a6a376237e6aaeb8e09ecced Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 20:35:27 +0100 Subject: [PATCH 7/8] Address remaining PR review comments - Fix getAllInstances to handle empty instance array without throwing - Persist updated token expiry timestamps to disk after refresh - Mark internal httpJson helpers as @internal instead of @public Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/cli-module-auth/src/lib/storage.ts | 3 +++ packages/cli-node/src/auth/CliAuth.test.ts | 4 ++++ packages/cli-node/src/auth/CliAuth.ts | 13 ++++++++----- packages/cli-node/src/auth/httpJson.ts | 4 ++-- packages/cli-node/src/auth/storage.ts | 19 +++++++++++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index 07e48271a8..c5d275149c 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -96,6 +96,9 @@ export async function getAllInstances(): Promise<{ selected: StoredInstance | undefined; }> { const { instances } = await readAll(); + if (instances.length === 0) { + return { instances: [], selected: undefined }; + } const selected = instances.find(i => i.selected) ?? instances[0]; return { instances: instances.map(i => ({ diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts index 65aa340d33..4b7cb61f5a 100644 --- a/packages/cli-node/src/auth/CliAuth.test.ts +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -145,6 +145,10 @@ describe('CliAuth', () => { 'refreshToken', 'new-refresh-token', ); + expect(mockStorage.updateInstance).toHaveBeenCalledWith('production', { + issuedAt: expect.any(Number), + accessTokenExpiresAt: expect.any(Number), + }); }); it('throws when refresh token is missing and access token has expired', async () => { diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts index d65fb9bbfa..6f1fae221e 100644 --- a/packages/cli-node/src/auth/CliAuth.ts +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -19,6 +19,7 @@ import { getSelectedInstance, getInstanceMetadata, updateInstanceMetadata, + updateInstance, accessTokenNeedsRefresh, } from './storage'; import { getSecretStore, type SecretStore } from './secretStore'; @@ -151,10 +152,12 @@ export class CliAuth { if (token.refresh_token) { await this.#secretStore.set(service, 'refreshToken', token.refresh_token); } - this.#instance = { - ...this.#instance, - issuedAt: Date.now(), - accessTokenExpiresAt: Date.now() + token.expires_in * 1000, - }; + const issuedAt = Date.now(); + const accessTokenExpiresAt = Date.now() + token.expires_in * 1000; + this.#instance = { ...this.#instance, issuedAt, accessTokenExpiresAt }; + await updateInstance(this.#instance.name, { + issuedAt, + accessTokenExpiresAt, + }); } } diff --git a/packages/cli-node/src/auth/httpJson.ts b/packages/cli-node/src/auth/httpJson.ts index 4861a07722..832178befb 100644 --- a/packages/cli-node/src/auth/httpJson.ts +++ b/packages/cli-node/src/auth/httpJson.ts @@ -16,7 +16,7 @@ import { ResponseError } from '@backstage/errors'; -/** @public */ +/** @internal */ export type HttpInit = { headers?: Record; method?: string; @@ -24,7 +24,7 @@ export type HttpInit = { signal?: AbortSignal; }; -/** @public */ +/** @internal */ export async function httpJson(url: string, init?: HttpInit): Promise { const res = await fetch(url, { ...init, diff --git a/packages/cli-node/src/auth/storage.ts b/packages/cli-node/src/auth/storage.ts index 13a866d058..c58bd94505 100644 --- a/packages/cli-node/src/auth/storage.ts +++ b/packages/cli-node/src/auth/storage.ts @@ -124,6 +124,9 @@ export async function getAllInstances(): Promise<{ selected: StoredInstance | undefined; }> { const { instances } = await readAll(); + if (instances.length === 0) { + return { instances: [], selected: undefined }; + } const selected = instances.find(i => i.selected) ?? instances[0]; return { instances: instances.map(i => ({ @@ -189,6 +192,22 @@ export async function updateInstanceMetadata( }); } +/** @internal */ +export async function updateInstance( + instanceName: string, + updates: Partial>, +): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instanceName); + if (idx === -1) { + throw new NotFoundError(`Instance '${instanceName}' not found`); + } + data.instances[idx] = { ...data.instances[idx], ...updates }; + await writeAll(data); + }); +} + /** @internal */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { // 2 minutes before expiration From db4f942f0857d7ca8116000db988014758105690 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 17 Mar 2026 20:54:43 +0100 Subject: [PATCH 8/8] plugins/{app,app-react}: revert API report changes Signed-off-by: Patrik Oldsberg --- plugins/app-react/report.api.md | 4 ++-- plugins/app/report.api.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 4e3fff6e42..a4e63e254b 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -86,7 +86,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ }; output: ExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {} @@ -97,7 +97,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{ dataRefs: { icons: ConfigurableExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {} diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index baa3b714b0..4c9b2a8ce4 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -478,7 +478,7 @@ const appPlugin: OverridableFrontendPlugin< icons: ExtensionInput< ConfigurableExtensionDataRef< { - [x: string]: IconElement | IconComponent; + [x: string]: IconComponent | IconElement; }, 'core.icons', {}