Merge pull request #33396 from backstage/rugvip/refactor-cli-auth-into-cli-node

cli-node: add CliAuth class for shared CLI authentication
This commit is contained in:
Patrik Oldsberg
2026-03-17 21:32:07 +01:00
committed by GitHub
40 changed files with 1096 additions and 318 deletions
-5
View File
@@ -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`.
+5
View File
@@ -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.
@@ -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,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<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
@@ -55,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() {
@@ -74,23 +82,30 @@ 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 */
export async function getSecretStore(): Promise<SecretStore> {
if (!singleton) {
const keytar = await loadKeytar();
+3 -2
View File
@@ -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:^",
@@ -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) {
@@ -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<string[]>(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`);
};
@@ -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<string[]>(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');
@@ -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<string[]>(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),
);
@@ -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(),
}));
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { httpJson } from '@backstage/cli-module-auth';
import { httpJson } from './httpJson';
export type ActionDef = {
id: string;
@@ -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<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
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;
}
@@ -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<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,
};
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();
@@ -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<string[]>(instance.name, 'pluginSources')) ?? [];
return { 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"
}
}
-58
View File
@@ -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<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined>;
// @public (undocumented)
export function getSecretStore(): Promise<SecretStore>;
// @public (undocumented)
export function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance>;
// @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 (undocumented)
export function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance>;
// @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 (undocumented)
export function updateInstanceConfig(
instanceName: string,
key: string,
value: unknown,
): Promise<void>;
// (No @packageDocumentation comment for this package)
```
@@ -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) {
@@ -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`);
};
+4 -17
View File
@@ -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`,
{
-14
View File
@@ -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';
@@ -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 () => {
+5 -6
View File
@@ -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<StoredInstance> {
@@ -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(
-2
View File
@@ -16,7 +16,6 @@
import { ResponseError } from '@backstage/errors';
/** @public */
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
@@ -24,7 +23,6 @@ export type HttpInit = {
signal?: AbortSignal;
};
/** @public */
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
const res = await fetch(url, {
...init,
@@ -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();
@@ -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();
});
});
+19 -21
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
};
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<StoredInstance> {
@@ -171,17 +171,15 @@ export async function setSelectedInstance(name: string): Promise<void> {
});
}
/** @public */
export async function getInstanceConfig<T = unknown>(
export async function getInstanceMetadata(
instanceName: string,
key: string,
): Promise<T | undefined> {
): Promise<unknown> {
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);
});
+5
View File
@@ -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"
},
+15
View File
@@ -86,6 +86,21 @@ export interface BackstagePackageJson {
version: string;
}
// @public
export class CliAuth {
static create(options?: CliAuthCreateOptions): Promise<CliAuth>;
getAccessToken(): Promise<string>;
getBaseUrl(): string;
getInstanceName(): string;
getMetadata(key: string): Promise<unknown>;
setMetadata(key: string, value: unknown): Promise<void>;
}
// @public
export interface CliAuthCreateOptions {
instanceName?: string;
}
// @public
export interface CliCommand {
deprecated?: boolean;
+226
View File
@@ -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<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.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'],
);
});
});
});
+163
View File
@@ -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<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;
}
/** 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<string> {
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<unknown> {
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<void> {
return updateInstanceMetadata(this.#instance.name, key, value);
}
async #refreshAccessToken(): Promise<void> {
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<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);
}
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,
});
}
}
@@ -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}`;
}
+41
View File
@@ -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<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
/** @internal */
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;
}
+17
View File
@@ -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';
+129
View File
@@ -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<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;
/** @internal */
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;
}
+215
View File
@@ -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<string, unknown>;
};
async function pathExists(p: string): Promise<boolean> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<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 getInstanceMetadata(
instanceName: string,
key: string,
): Promise<unknown> {
const instance = await getInstanceByName(instanceName);
return instance.metadata?.[key];
}
/** @internal */
export async function updateInstanceMetadata(
instanceName: string,
key: string,
value: unknown,
): Promise<void> {
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<Pick<StoredInstance, 'issuedAt' | 'accessTokenExpiresAt'>>,
): Promise<void> {
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;
}
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './auth';
export * from './cache';
export * from './cli-module';
export * from './concurrency';
+8 -1
View File
@@ -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