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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 17:10:01 +01:00
parent da8e6603a4
commit 2b90358730
23 changed files with 160 additions and 89 deletions
@@ -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}`;
}
+6
View File
@@ -25,3 +25,9 @@ export {
OpaqueCommandLeafNode,
isCommandNodeHidden,
} from './InternalCommandNode';
export { getAuthInstanceService } from './authIdentifiers';
export {
getSecretStore,
resetSecretStore,
type SecretStore,
} from './secretStore';
@@ -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<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
@@ -54,6 +54,15 @@ class KeytarSecretStore implements SecretStore {
}
}
async function pathExists(p: string): Promise<boolean> {
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<string | undefined> {
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<void> {
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<void> {
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;
}
}
}
}
@@ -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);
@@ -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) {
@@ -40,7 +40,7 @@ export default async ({ args, info }: CliCommandContext) => {
return;
}
await updateInstanceConfig(auth.instanceName, 'pluginSources', [
await updateInstanceConfig(auth.getInstanceName(), 'pluginSources', [
...existing,
pluginId,
]);
@@ -39,7 +39,7 @@ export default async ({ args, info }: CliCommandContext) => {
}
await updateInstanceConfig(
auth.instanceName,
auth.getInstanceName(),
'pluginSources',
existing.filter(s => s !== pluginId),
);
@@ -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<typeof CliAuth.create>;
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);
@@ -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<string[]>('pluginSources')) ?? [];
return { instance: auth.instance, accessToken, pluginSources };
return {
baseUrl: auth.getBaseUrl(),
instanceName: auth.getInstanceName(),
accessToken,
pluginSources,
};
}
+2 -2
View File
@@ -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"
}
}
@@ -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);
@@ -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) {
@@ -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(/\/$/, '');
@@ -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<typeof storage>;
const mockSecretStore = secretStore as jest.Mocked<typeof secretStore>;
const mockInternalCli = internalCli as jest.Mocked<typeof internalCli>;
const mockHttp = http as jest.Mocked<typeof http>;
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 () => {
+8 -4
View File
@@ -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(
@@ -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();
+9 -2
View File
@@ -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<string, unknown>;
};
const METADATA_FILE = 'auth-instances.yaml';
+2 -14
View File
@@ -88,12 +88,11 @@ export interface BackstagePackageJson {
// @public
export class CliAuth {
get baseUrl(): string;
static create(options?: CliAuthCreateOptions): Promise<CliAuth>;
getAccessToken(): Promise<string>;
getBaseUrl(): string;
getConfig<T = unknown>(key: string): Promise<T | undefined>;
get instance(): StoredInstance;
get instanceName(): string;
getInstanceName(): string;
}
// @public
@@ -287,17 +286,6 @@ export function runWorkerQueueThreads<TItem, TResult, TContext>(
results: TResult[];
}>;
// @public (undocumented)
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
// @public
export class SuccessCache {
// (undocumented)
+2 -3
View File
@@ -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 () => {
+7 -11
View File
@@ -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<void> {
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) {
@@ -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}`;
}
-1
View File
@@ -15,4 +15,3 @@
*/
export { CliAuth, type CliAuthCreateOptions } from './CliAuth';
export { type StoredInstance } from './storage';
+23 -6
View File
@@ -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<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
@@ -55,6 +55,15 @@ class KeytarSecretStore implements SecretStore {
}
}
async function pathExists(p: string): Promise<boolean> {
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<string | undefined> {
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<void> {
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<void> {
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<SecretStore> {
if (!singleton) {
const keytar = await loadKeytar();