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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user