cli-node: add CliAuth class for shared CLI authentication
Introduces a class-based authentication management API in @backstage/cli-node that reads the on-disk instance store, transparently refreshes expired tokens, and provides a convenient surface for other CLI modules to consume. The split keeps filesystem-based instance selection and writes owned by cli-module-auth, while reading and consuming the current instance is available through CliAuth in cli-node. Migrates cli-module-actions to use the new API and deprecates the ad-hoc function exports from cli-module-auth. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -52,6 +52,9 @@
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": "^1.15.6"
|
||||
},
|
||||
|
||||
@@ -86,6 +86,21 @@ export interface BackstagePackageJson {
|
||||
version: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class CliAuth {
|
||||
get baseUrl(): string;
|
||||
static create(options?: CliAuthCreateOptions): Promise<CliAuth>;
|
||||
getAccessToken(): Promise<string>;
|
||||
getConfig<T = unknown>(key: string): Promise<T | undefined>;
|
||||
get instance(): StoredInstance;
|
||||
get instanceName(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CliAuthCreateOptions {
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CliCommand {
|
||||
deprecated?: boolean;
|
||||
@@ -133,6 +148,9 @@ export function createCliModule(options: {
|
||||
}) => Promise<void>;
|
||||
}): CliModule;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSecretStore(): Promise<SecretStore>;
|
||||
|
||||
// @public
|
||||
export class GitUtils {
|
||||
static listChangedFiles(ref: string): Promise<string[]>;
|
||||
@@ -142,6 +160,17 @@ export class GitUtils {
|
||||
// @public
|
||||
export function hasBackstageYarnPlugin(workspaceDir?: string): Promise<boolean>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
|
||||
|
||||
// @public
|
||||
export function isMonoRepo(): Promise<boolean>;
|
||||
|
||||
@@ -272,6 +301,24 @@ export function runWorkerQueueThreads<TItem, TResult, TContext>(
|
||||
results: TResult[];
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
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>;
|
||||
};
|
||||
|
||||
// @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)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CliAuth } from './CliAuth';
|
||||
import * as storage from './storage';
|
||||
import * as secretStoreModule from './secretStore';
|
||||
import * as httpModule from './httpJson';
|
||||
|
||||
jest.mock('./storage');
|
||||
jest.mock('./secretStore');
|
||||
jest.mock('./httpJson');
|
||||
|
||||
const mockStorage = storage as jest.Mocked<typeof storage>;
|
||||
const mockSecretStoreModule = secretStoreModule as jest.Mocked<
|
||||
typeof secretStoreModule
|
||||
>;
|
||||
const mockHttp = httpModule as jest.Mocked<typeof httpModule>;
|
||||
|
||||
describe('CliAuth', () => {
|
||||
const now = Date.now();
|
||||
const mockInstance = {
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'prod-client',
|
||||
issuedAt: now,
|
||||
accessTokenExpiresAt: now + 3600_000,
|
||||
};
|
||||
|
||||
const mockSecretStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockStorage.getSelectedInstance.mockResolvedValue(mockInstance);
|
||||
mockSecretStoreModule.getSecretStore.mockResolvedValue(mockSecretStore);
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(false);
|
||||
mockSecretStore.get.mockResolvedValue('test-access-token');
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('resolves the currently selected instance by default', async () => {
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith(undefined);
|
||||
expect(auth.instance).toEqual(mockInstance);
|
||||
expect(auth.instanceName).toBe('production');
|
||||
expect(auth.baseUrl).toBe('https://backstage.example.com');
|
||||
});
|
||||
|
||||
it('resolves a named instance when specified', async () => {
|
||||
await CliAuth.create({ instanceName: 'staging' });
|
||||
|
||||
expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith('staging');
|
||||
});
|
||||
|
||||
it('throws when no instance can be found', async () => {
|
||||
mockStorage.getSelectedInstance.mockRejectedValue(
|
||||
new Error(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
),
|
||||
);
|
||||
|
||||
await expect(CliAuth.create()).rejects.toThrow(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('returns a stored access token when it is still valid', async () => {
|
||||
const auth = await CliAuth.create();
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
expect(token).toBe('test-access-token');
|
||||
expect(mockSecretStore.get).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'accessToken',
|
||||
);
|
||||
expect(mockHttp.httpJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when no access token is stored', async () => {
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the token when it is about to expire', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'refreshToken') return 'old-refresh-token';
|
||||
if (account === 'accessToken') return 'new-access-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
});
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
expect(token).toBe('new-access-token');
|
||||
expect(mockHttp.httpJson).toHaveBeenCalledWith(
|
||||
'https://backstage.example.com/api/auth/v1/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockSecretStore.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'accessToken',
|
||||
'new-access-token',
|
||||
);
|
||||
expect(mockSecretStore.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'refreshToken',
|
||||
'new-refresh-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when refresh token is missing and access token has expired', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the token response is malformed', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'refreshToken') return 'refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'Invalid token response',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('returns a config value from the instance', async () => {
|
||||
mockStorage.getInstanceConfig.mockResolvedValue([
|
||||
'catalog',
|
||||
'scaffolder',
|
||||
]);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const sources = await auth.getConfig<string[]>('pluginSources');
|
||||
|
||||
expect(sources).toEqual(['catalog', 'scaffolder']);
|
||||
expect(mockStorage.getInstanceConfig).toHaveBeenCalledWith(
|
||||
'production',
|
||||
'pluginSources',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for missing config keys', async () => {
|
||||
mockStorage.getInstanceConfig.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const value = await auth.getConfig('nonexistent');
|
||||
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type StoredInstance,
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
accessTokenNeedsRefresh,
|
||||
} from './storage';
|
||||
import { getSecretStore, type SecretStore } from './secretStore';
|
||||
import { httpJson } from './httpJson';
|
||||
import { z } from 'zod';
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
token_type: z.string().min(1),
|
||||
expires_in: z.number().positive().finite(),
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Options for creating a {@link CliAuth} instance.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CliAuthCreateOptions {
|
||||
/**
|
||||
* An explicit instance name to resolve. When omitted the currently
|
||||
* selected instance is used.
|
||||
*/
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages authentication state for Backstage CLI commands.
|
||||
*
|
||||
* Reads the currently selected (or explicitly named) auth instance from
|
||||
* the on-disk instance store, transparently refreshes expired access
|
||||
* tokens, and exposes helpers that other CLI modules need to talk to a
|
||||
* Backstage backend.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CliAuth {
|
||||
readonly #secretStore: SecretStore;
|
||||
#instance: StoredInstance;
|
||||
|
||||
/**
|
||||
* Resolve the current auth instance and return a ready-to-use
|
||||
* {@link CliAuth} object. Throws when no instance can be found.
|
||||
*/
|
||||
static async create(options?: CliAuthCreateOptions): Promise<CliAuth> {
|
||||
const instance = await getSelectedInstance(options?.instanceName);
|
||||
const secretStore = await getSecretStore();
|
||||
return new CliAuth(instance, secretStore);
|
||||
}
|
||||
|
||||
private constructor(instance: StoredInstance, secretStore: SecretStore) {
|
||||
this.#instance = instance;
|
||||
this.#secretStore = secretStore;
|
||||
}
|
||||
|
||||
/** The resolved instance metadata. */
|
||||
get instance(): StoredInstance {
|
||||
return this.#instance;
|
||||
}
|
||||
|
||||
/** Shorthand for `instance.name`. */
|
||||
get instanceName(): string {
|
||||
return this.#instance.name;
|
||||
}
|
||||
|
||||
/** Shorthand for `instance.baseUrl`. */
|
||||
get baseUrl(): string {
|
||||
return this.#instance.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid access token, refreshing it first if the current
|
||||
* token is expired or about to expire.
|
||||
*/
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (accessTokenNeedsRefresh(this.#instance)) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
|
||||
const service = `backstage-cli:auth-instance:${this.#instance.name}`;
|
||||
const token = await this.#secretStore.get(service, 'accessToken');
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a per-instance configuration value previously stored by the
|
||||
* auth module (e.g. `pluginSources`).
|
||||
*/
|
||||
async getConfig<T = unknown>(key: string): Promise<T | undefined> {
|
||||
return getInstanceConfig<T>(this.#instance.name, key);
|
||||
}
|
||||
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
const service = `backstage-cli:auth-instance:${this.#instance.name}`;
|
||||
const refreshToken =
|
||||
(await this.#secretStore.get(service, 'refreshToken')) ?? '';
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await httpJson<unknown>(
|
||||
`${this.#instance.baseUrl}/api/auth/v1/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = TokenResponseSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid token response: ${parsed.error.message}`);
|
||||
}
|
||||
const token = parsed.data;
|
||||
|
||||
await this.#secretStore.set(service, 'accessToken', token.access_token);
|
||||
if (token.refresh_token) {
|
||||
await this.#secretStore.set(service, 'refreshToken', token.refresh_token);
|
||||
}
|
||||
this.#instance = {
|
||||
...this.#instance,
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
/** @public */
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CliAuth, type CliAuthCreateOptions } from './CliAuth';
|
||||
export { type StoredInstance } from './storage';
|
||||
export { httpJson, type HttpInit } from './httpJson';
|
||||
export { getSecretStore, type SecretStore } from './secretStore';
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** @public */
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<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;
|
||||
|
||||
/** @public */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const METADATA_FILE = 'auth-instances.yaml';
|
||||
|
||||
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
|
||||
|
||||
const storedInstanceSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(INSTANCE_NAME_PATTERN, 'Instance name contains invalid characters'),
|
||||
baseUrl: z.string().url(),
|
||||
clientId: z.string().min(1),
|
||||
issuedAt: z.number().int().nonnegative(),
|
||||
accessTokenExpiresAt: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const authYamlSchema = z.object({
|
||||
instances: z.array(storedInstanceSchema).default([]),
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
export function getMetadataFilePath(): string {
|
||||
const root =
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
(process.platform === 'win32'
|
||||
? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
: path.join(os.homedir(), '.config'));
|
||||
|
||||
return path.join(root, 'backstage-cli', METADATA_FILE);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function readAll(): Promise<{ instances: StoredInstance[] }> {
|
||||
const file = getMetadataFilePath();
|
||||
if (!(await fs.pathExists(file))) {
|
||||
return { instances: [] };
|
||||
}
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
if (!text.trim()) {
|
||||
return { instances: [] };
|
||||
}
|
||||
try {
|
||||
const doc = YAML.parse(text);
|
||||
const parsed = authYamlSchema.safeParse(doc);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
return { instances: [] };
|
||||
} catch {
|
||||
return { instances: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getAllInstances(): Promise<{
|
||||
instances: StoredInstance[];
|
||||
selected: StoredInstance | undefined;
|
||||
}> {
|
||||
const { instances } = await readAll();
|
||||
const selected = instances.find(i => i.selected) ?? instances[0];
|
||||
return {
|
||||
instances: instances.map(i => ({
|
||||
...i,
|
||||
selected: i.name === selected.name,
|
||||
})),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance> {
|
||||
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<StoredInstance> {
|
||||
const { instances } = await readAll();
|
||||
const instance = instances.find(i => i.name === name);
|
||||
if (!instance) {
|
||||
throw new NotFoundError(`Instance '${name}' not found`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getInstanceConfig<T = unknown>(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<T | undefined> {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
return instance.config?.[key] as T | undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
|
||||
// 2 minutes before expiration
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000;
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './auth';
|
||||
export * from './cache';
|
||||
export * from './cli-module';
|
||||
export * from './concurrency';
|
||||
|
||||
Reference in New Issue
Block a user