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';
+127
View File
@@ -0,0 +1,127 @@
/*
* 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';
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>;
};
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);
}
}
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() {
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 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.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);
try {
await fs.unlink(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
}
}
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;
}