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
+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();
@@ -1,110 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
};
async function loadKeytar(): Promise<typeof import('keytar') | undefined> {
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<string | undefined> {
const result = await this.keytar.getPassword(service, account);
return result ?? undefined;
}
async set(service: string, account: string, secret: string): Promise<void> {
await this.keytar.setPassword(service, account, secret);
}
async delete(service: string, account: string): Promise<void> {
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<string | undefined> {
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<void> {
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<void> {
const file = this.filePath(service, account);
await fs.remove(file);
}
}
let singleton: SecretStore | undefined;
export async function getSecretStore(): Promise<SecretStore> {
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;
}
+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';