diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md deleted file mode 100644 index 0b6dc46ac3..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. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md new file mode 100644 index 0000000000..b4b943af5a --- /dev/null +++ b/.changeset/cli-node-auth-api.md @@ -0,0 +1,5 @@ +--- +'@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-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 88% rename from packages/cli-module-auth/src/lib/secretStore.ts rename to packages/cli-internal/src/secretStore.ts index a0f3c81d51..26c1e6ad2b 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-internal/src/secretStore.ts @@ -14,11 +14,10 @@ * 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 */ export type SecretStore = { get(service: string, account: string): Promise; set(service: string, account: string, secret: string): Promise; @@ -55,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() { @@ -74,23 +82,30 @@ 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 */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index fe19a8a90d..86c9997322 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -33,9 +33,10 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-node": "workspace:^", - "cleye": "^2.3.0" + "@backstage/errors": "workspace:^", + "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/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 2a8135b63f..fee3007637 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -15,12 +15,10 @@ */ 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 { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,9 +32,10 @@ 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 = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (existing.includes(pluginId)) { process.stderr.write( @@ -45,10 +44,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(instance.name, '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 68b368efd5..7557922c06 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -15,18 +15,18 @@ */ 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'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); 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 = 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 731abe790d..9225228503 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -15,12 +15,10 @@ */ 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 { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,17 +32,17 @@ 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 = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (!existing.includes(pluginId)) { process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); return; } - await updateInstanceConfig( - instance.name, + await auth.setMetadata( '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..3aa2e06dc7 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.test.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -15,9 +15,9 @@ */ import { ActionsClient } from './ActionsClient'; -import { httpJson } from '@backstage/cli-module-auth'; +import { httpJson } from './httpJson'; -jest.mock('@backstage/cli-module-auth', () => ({ +jest.mock('./httpJson', () => ({ httpJson: jest.fn(), })); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts index aa04902b2f..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-module-auth'; +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-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index f2702cf757..2aabefe9ea 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -15,99 +15,66 @@ */ import { resolveAuth } from './resolveAuth'; -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +import { CliAuth } 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 = { - name: 'production', - baseUrl: 'https://backstage.example.com', - clientId: 'my-client', - issuedAt: Date.now(), - 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({ + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getMetadata: 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, + baseUrl: 'https://backstage.example.com', + instanceName: 'production', accessToken: 'test-access-token', pluginSources: ['catalog', 'scaffolder'], }); }); - it('passes instance name flag to getSelectedInstance', async () => { + it('passes instance name flag to CliAuth.create', async () => { + mockCreate.mockResolvedValue({ + getInstanceName: jest.fn().mockReturnValue('staging'), + getBaseUrl: jest.fn().mockReturnValue('https://staging.example.com'), + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getMetadata: 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({ + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), + getAccessToken: jest + .fn() + .mockRejectedValue( + new Error('No access token found. Run "auth login" to authenticate.'), + ), + getMetadata: jest.fn().mockResolvedValue([]), + } as unknown as CliAuth); await expect(resolveAuth()).rejects.toThrow( 'No access token found. Run "auth login" to authenticate.', @@ -115,7 +82,12 @@ describe('resolveAuth', () => { }); it('returns empty plugin sources when none are configured', async () => { - mockGetInstanceConfig.mockResolvedValue(undefined); + mockCreate.mockResolvedValue({ + getInstanceName: jest.fn().mockReturnValue('production'), + getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + 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 11fb646545..568bd4cdcd 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -14,35 +14,27 @@ * limitations under the License. */ -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +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<{ - instance: StoredInstance; + baseUrl: string; + instanceName: string; accessToken: string; pluginSources: string[]; }> { - let instance = await getSelectedInstance(instanceFlag); + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); + const pluginSources = pluginSourcesSchema.parse( + await auth.getMetadata('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 { + 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/report.api.md b/packages/cli-module-auth/report.api.md index a3b98e2f02..510bcaabe7 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -5,67 +5,9 @@ ```ts import { CliModule } from '@backstage/cli-node'; -// @public (undocumented) -export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; - // @public (undocumented) const _default: CliModule; export default _default; -// @public (undocumented) -export function getInstanceConfig( - instanceName: string, - key: string, -): Promise; - -// @public (undocumented) -export function getSecretStore(): Promise; - -// @public (undocumented) -export function getSelectedInstance( - instanceName?: 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 (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; -}; - -// @public (undocumented) -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; - -// @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/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/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..275b6ca66d 100644 --- a/packages/cli-module-auth/src/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -15,11 +15,8 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; +import { CliAuth, 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'; export default async ({ args, info }: CliCommandContext) => { const { @@ -38,23 +35,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.getBaseUrl()) .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..fef74ec8a5 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -52,17 +52,3 @@ export default createCliModule({ }); }, }); - -/** @public */ -export { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, - type StoredInstance, -} from './lib/storage'; -/** @public */ -export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; -/** @public */ -export { getSecretStore, type SecretStore } from './lib/secretStore'; -/** @public */ -export { httpJson, type HttpInit } from './lib/http'; 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 36bdf3dfbc..1ad108cbf1 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -16,12 +16,12 @@ import { z } from 'zod/v3'; import { - StoredInstance, + type StoredInstance, upsertInstance, withMetadataLock, getInstanceByName, } from './storage'; -import { getSecretStore } from './secretStore'; +import { getSecretStore, getAuthInstanceService } from '@internal/cli'; import { httpJson } from './http'; const TokenResponseSchema = z.object({ @@ -31,12 +31,11 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); -/** @public */ 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 */ export async function refreshAccessToken( instanceName: string, ): Promise { @@ -45,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/http.ts b/packages/cli-module-auth/src/lib/http.ts index 4861a07722..4daaf823fb 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -16,7 +16,6 @@ import { ResponseError } from '@backstage/errors'; -/** @public */ export type HttpInit = { headers?: Record; method?: string; @@ -24,7 +23,6 @@ export type HttpInit = { signal?: AbortSignal; }; -/** @public */ export async function httpJson(url: string, init?: HttpInit): Promise { const res = await fetch(url, { ...init, 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.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 0e7668a0ea..c5d275149c 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -22,6 +22,16 @@ import lockfile from 'proper-lockfile'; import YAML from 'yaml'; import { z } from 'zod/v3'; +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + metadata?: Record; +}; + const METADATA_FILE = 'auth-instances.yaml'; const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/; @@ -36,20 +46,9 @@ 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(), }); -/** @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([]), }); @@ -97,9 +96,11 @@ 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 { - // Normalize selection prop instances: instances.map(i => ({ ...i, selected: i.name === selected.name, @@ -108,7 +109,6 @@ export async function getAllInstances(): Promise<{ }; } -/** @public */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -171,17 +171,15 @@ export async function setSelectedInstance(name: string): Promise { }); } -/** @public */ -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, @@ -194,7 +192,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 fbefa34fcc..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,8 +51,12 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", + "@types/proper-lockfile": "^4", "@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..9f04b80ddf 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 { + static create(options?: CliAuthCreateOptions): Promise; + getAccessToken(): Promise; + getBaseUrl(): string; + getInstanceName(): string; + getMetadata(key: string): Promise; + setMetadata(key: string, value: unknown): Promise; +} + +// @public +export interface CliAuthCreateOptions { + instanceName?: string; +} + // @public export interface CliCommand { deprecated?: boolean; 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..4b7cb61f5a --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -0,0 +1,226 @@ +/* + * 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.getInstanceName()).toBe('production'); + expect(auth.getBaseUrl()).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', + ); + 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 () => { + 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('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.getMetadata('pluginSources'); + + expect(sources).toEqual(['catalog', 'scaffolder']); + expect(mockStorage.getInstanceMetadata).toHaveBeenCalledWith( + 'production', + 'pluginSources', + ); + }); + + it('returns undefined for missing metadata keys', async () => { + mockStorage.getInstanceMetadata.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + 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 new file mode 100644 index 0000000000..6f1fae221e --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -0,0 +1,163 @@ +/* + * 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, + getInstanceMetadata, + updateInstanceMetadata, + updateInstance, + accessTokenNeedsRefresh, +} from './storage'; +import { getSecretStore, type SecretStore } from './secretStore'; +import { getAuthInstanceService } from './authIdentifiers'; +import { httpJson } from './httpJson'; +import { z } from 'zod/v3'; + +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; + } + + /** Returns the name of the resolved auth instance. */ + getInstanceName(): string { + return this.#instance.name; + } + + /** Returns the base URL of the resolved auth instance. */ + getBaseUrl(): 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 = getAuthInstanceService(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 metadata value previously stored by the + * auth module (e.g. `pluginSources`). + */ + 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 { + const service = getAuthInstanceService(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); + } + 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/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/httpJson.ts b/packages/cli-node/src/auth/httpJson.ts new file mode 100644 index 0000000000..832178befb --- /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'; + +/** @internal */ +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +/** @internal */ +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..d6a1b08d65 --- /dev/null +++ b/packages/cli-node/src/auth/index.ts @@ -0,0 +1,17 @@ +/* + * 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'; diff --git a/packages/cli-node/src/auth/secretStore.ts b/packages/cli-node/src/auth/secretStore.ts new file mode 100644 index 0000000000..70ef5f2f72 --- /dev/null +++ b/packages/cli-node/src/auth/secretStore.ts @@ -0,0 +1,129 @@ +/* + * 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 { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** @internal */ +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); + } +} + +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() { + 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 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.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); + try { + await fs.unlink(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + } +} + +let singleton: SecretStore | undefined; + +/** @internal */ +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..c58bd94505 --- /dev/null +++ b/packages/cli-node/src/auth/storage.ts @@ -0,0 +1,215 @@ +/* + * 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 { 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/v3'; + +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(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), +}); + +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + metadata?: Record; +}; + +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +/** @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 pathExists(file))) { + return { instances: [] }; + } + const text = await fs.readFile(file, 'utf8'); + if (!text.trim()) { + return { instances: [] }; + } + try { + const doc = YAML.parse(text); + const parsed = authYamlSchema.safeParse(doc); + if (parsed.success) { + return parsed.data; + } + return { instances: [] }; + } catch { + return { instances: [] }; + } +} + +async function writeAll(data: { instances: StoredInstance[] }): Promise { + const file = getMetadataFilePath(); + await fs.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[]; + 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 => ({ + ...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 getInstanceMetadata( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + 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 */ +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 + 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..299ee9995b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2828,9 +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 @@ -3153,18 +3154,24 @@ __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" 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" + proper-lockfile: "npm:^4.1.2" 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