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