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:
Patrik Oldsberg
2026-03-17 13:17:09 +01:00
parent df15d409a4
commit 12fa965e67
28 changed files with 866 additions and 244 deletions
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-actions': patch
---
Migrated to use `CliAuth` from `@backstage/cli-node` for authentication instead of importing individual functions from `@backstage/cli-module-auth`.
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-auth': patch
---
Deprecated `getSelectedInstance`, `getInstanceConfig`, `accessTokenNeedsRefresh`, `refreshAccessToken`, `getSecretStore`, and `httpJson` exports in favor of the new `CliAuth` class and shared utilities from `@backstage/cli-node`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-node': minor
---
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. Also added `httpJson`, `getSecretStore`, `SecretStore`, `StoredInstance`, and `HttpInit` exports.
@@ -15,12 +15,8 @@
*/
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 { updateInstanceConfig } from '@backstage/cli-module-auth';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
@@ -34,9 +30,8 @@ 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 = (await auth.getConfig<string[]>('pluginSources')) ?? [];
if (existing.includes(pluginId)) {
process.stderr.write(
@@ -45,7 +40,7 @@ export default async ({ args, info }: CliCommandContext) => {
return;
}
await updateInstanceConfig(instance.name, 'pluginSources', [
await updateInstanceConfig(auth.instanceName, 'pluginSources', [
...existing,
pluginId,
]);
@@ -15,18 +15,13 @@
*/
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';
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 = (await auth.getConfig<string[]>('pluginSources')) ?? [];
if (!sources.length) {
process.stderr.write('No plugin sources configured.\n');
@@ -15,12 +15,8 @@
*/
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 { updateInstanceConfig } from '@backstage/cli-module-auth';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
@@ -34,9 +30,8 @@ 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 = (await auth.getConfig<string[]>('pluginSources')) ?? [];
if (!existing.includes(pluginId)) {
process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
@@ -44,7 +39,7 @@ export default async ({ args, info }: CliCommandContext) => {
}
await updateInstanceConfig(
instance.name,
auth.instanceName,
'pluginSources',
existing.filter(s => s !== pluginId),
);
@@ -15,11 +15,15 @@
*/
import { ActionsClient } from './ActionsClient';
import { httpJson } from '@backstage/cli-module-auth';
import { httpJson } from '@backstage/cli-node';
jest.mock('@backstage/cli-module-auth', () => ({
httpJson: jest.fn(),
}));
jest.mock('@backstage/cli-node', () => {
const actual = jest.requireActual('@backstage/cli-node');
return {
...actual,
httpJson: jest.fn(),
};
});
const mockHttpJson = httpJson as jest.MockedFunction<typeof httpJson>;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { httpJson } from '@backstage/cli-module-auth';
import { httpJson } from '@backstage/cli-node';
export type ActionDef = {
id: string;
@@ -15,39 +15,17 @@
*/
import { resolveAuth } from './resolveAuth';
import {
getSelectedInstance,
getInstanceConfig,
accessTokenNeedsRefresh,
refreshAccessToken,
getSecretStore,
type StoredInstance,
} from '@backstage/cli-module-auth';
import { CliAuth, type StoredInstance } 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 = {
@@ -58,27 +36,22 @@ describe('resolveAuth', () => {
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({
instance: mockInstance,
instanceName: mockInstance.name,
baseUrl: mockInstance.baseUrl,
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
getConfig: 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,
accessToken: 'test-access-token',
@@ -86,28 +59,32 @@ describe('resolveAuth', () => {
});
});
it('passes instance name flag to getSelectedInstance', async () => {
it('passes instance name flag to CliAuth.create', async () => {
mockCreate.mockResolvedValue({
instance: mockInstance,
instanceName: mockInstance.name,
baseUrl: mockInstance.baseUrl,
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
getConfig: 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({
instance: mockInstance,
instanceName: mockInstance.name,
baseUrl: mockInstance.baseUrl,
getAccessToken: jest
.fn()
.mockRejectedValue(
new Error('No access token found. Run "auth login" to authenticate.'),
),
getConfig: jest.fn().mockResolvedValue([]),
} as unknown as CliAuth);
await expect(resolveAuth()).rejects.toThrow(
'No access token found. Run "auth login" to authenticate.',
@@ -115,7 +92,13 @@ describe('resolveAuth', () => {
});
it('returns empty plugin sources when none are configured', async () => {
mockGetInstanceConfig.mockResolvedValue(undefined);
mockCreate.mockResolvedValue({
instance: mockInstance,
instanceName: mockInstance.name,
baseUrl: mockInstance.baseUrl,
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
getConfig: jest.fn().mockResolvedValue(undefined),
} as unknown as CliAuth);
const result = await resolveAuth();
@@ -14,35 +14,16 @@
* limitations under the License.
*/
import {
getSelectedInstance,
getInstanceConfig,
accessTokenNeedsRefresh,
refreshAccessToken,
getSecretStore,
type StoredInstance,
} from '@backstage/cli-module-auth';
import { CliAuth, type StoredInstance } from '@backstage/cli-node';
export async function resolveAuth(instanceFlag?: string): Promise<{
instance: StoredInstance;
accessToken: string;
pluginSources: string[];
}> {
let instance = await getSelectedInstance(instanceFlag);
const auth = await CliAuth.create({ instanceName: instanceFlag });
const accessToken = await auth.getAccessToken();
const pluginSources = (await auth.getConfig<string[]>('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 { instance: auth.instance, accessToken, pluginSources };
}
+14 -31
View File
@@ -4,61 +4,44 @@
```ts
import { CliModule } from '@backstage/cli-node';
import { getSecretStore } from '@backstage/cli-node';
import { HttpInit } from '@backstage/cli-node';
import { httpJson } from '@backstage/cli-node';
import { SecretStore } from '@backstage/cli-node';
import { StoredInstance } from '@backstage/cli-node';
// @public (undocumented)
// @public @deprecated (undocumented)
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
// @public (undocumented)
const _default: CliModule;
export default _default;
// @public (undocumented)
// @public @deprecated (undocumented)
export function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined>;
// @public (undocumented)
export function getSecretStore(): Promise<SecretStore>;
export { getSecretStore };
// @public (undocumented)
// @public @deprecated (undocumented)
export function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
export { HttpInit };
// @public (undocumented)
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
export { httpJson };
// @public (undocumented)
// @public @deprecated (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>;
};
export { SecretStore };
// @public (undocumented)
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
export { StoredInstance };
// @public (undocumented)
export function updateInstanceConfig(
@@ -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 -18
View File
@@ -15,11 +15,7 @@
*/
import { cli } from 'cleye';
import 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';
import { CliAuth, httpJson, type CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
@@ -38,23 +34,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.baseUrl)
.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`,
{
+4 -4
View File
@@ -53,16 +53,16 @@ export default createCliModule({
},
});
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export {
getSelectedInstance,
getInstanceConfig,
updateInstanceConfig,
type StoredInstance,
} from './lib/storage';
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
/** @public */
/** @public @deprecated Import from {@link @backstage/cli-node} instead. */
export { getSecretStore, type SecretStore } from './lib/secretStore';
/** @public */
/** @public @deprecated Import from {@link @backstage/cli-node} instead. */
export { httpJson, type HttpInit } from './lib/http';
+6 -9
View File
@@ -15,12 +15,8 @@
*/
import { z } from 'zod/v3';
import {
StoredInstance,
upsertInstance,
withMetadataLock,
getInstanceByName,
} from './storage';
import type { StoredInstance } from '@backstage/cli-node';
import { upsertInstance, withMetadataLock, getInstanceByName } from './storage';
import { getSecretStore } from './secretStore';
import { httpJson } from './http';
@@ -31,12 +27,13 @@ const TokenResponseSchema = z.object({
refresh_token: z.string().min(1).optional(),
});
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
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 */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export async function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance> {
+1 -25
View File
@@ -14,28 +14,4 @@
* 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;
}
export { httpJson, type HttpInit } from '@backstage/cli-node';
@@ -18,12 +18,8 @@ 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>;
};
export type { SecretStore } from '@backstage/cli-node';
import type { SecretStore } from '@backstage/cli-node';
async function loadKeytar(): Promise<typeof import('keytar') | undefined> {
try {
+5 -14
View File
@@ -22,6 +22,9 @@ 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';
const METADATA_FILE = 'auth-instances.yaml';
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
@@ -39,17 +42,6 @@ const storedInstanceSchema = z.object({
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([]),
});
@@ -99,7 +91,6 @@ export async function getAllInstances(): Promise<{
const { instances } = await readAll();
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 +99,7 @@ export async function getAllInstances(): Promise<{
};
}
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export async function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance> {
@@ -171,7 +162,7 @@ export async function setSelectedInstance(name: string): Promise<void> {
});
}
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth.getConfig} instead. */
export async function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
+3
View File
@@ -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"
},
+47
View File
@@ -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)
+210
View File
@@ -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();
});
});
});
+156
View File
@@ -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,
};
}
}
+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';
/** @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;
}
+20
View File
@@ -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';
+112
View File
@@ -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;
}
+144
View File
@@ -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;
}
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './auth';
export * from './cache';
export * from './cli-module';
export * from './concurrency';
+4
View File
@@ -3159,12 +3159,16 @@ __metadata:
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"
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