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
@@ -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 };
}