Merge pull request #33396 from backstage/rugvip/refactor-cli-auth-into-cli-node
cli-node: add CliAuth class for shared CLI authentication
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/cli-module-auth': patch
|
||||
---
|
||||
|
||||
Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-node': patch
|
||||
---
|
||||
|
||||
Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend.
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** Returns the secret-store service key for a given auth instance. */
|
||||
export function getAuthInstanceService(instanceName: string): string {
|
||||
return `backstage-cli:auth-instance:${instanceName}`;
|
||||
}
|
||||
@@ -25,3 +25,9 @@ export {
|
||||
OpaqueCommandLeafNode,
|
||||
isCommandNodeHidden,
|
||||
} from './InternalCommandNode';
|
||||
export { getAuthInstanceService } from './authIdentifiers';
|
||||
export {
|
||||
getSecretStore,
|
||||
resetSecretStore,
|
||||
type SecretStore,
|
||||
} from './secretStore';
|
||||
|
||||
+21
-6
@@ -14,11 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** @public */
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
@@ -55,6 +54,15 @@ class KeytarSecretStore implements SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class FileSecretStore implements SecretStore {
|
||||
private readonly baseDir: string;
|
||||
constructor() {
|
||||
@@ -74,23 +82,30 @@ class FileSecretStore implements SecretStore {
|
||||
}
|
||||
async get(service: string, account: string): Promise<string | undefined> {
|
||||
const file = this.filePath(service, account);
|
||||
if (!(await fs.pathExists(file))) return undefined;
|
||||
if (!(await pathExists(file))) {
|
||||
return undefined;
|
||||
}
|
||||
return await fs.readFile(file, 'utf8');
|
||||
}
|
||||
async set(service: string, account: string, secret: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
await fs.ensureDir(path.dirname(file));
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, secret, { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
async delete(service: string, account: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
await fs.remove(file);
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: SecretStore | undefined;
|
||||
|
||||
/** @public */
|
||||
export async function getSecretStore(): Promise<SecretStore> {
|
||||
if (!singleton) {
|
||||
const keytar = await loadKeytar();
|
||||
@@ -33,9 +33,10 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli-module-auth": "workspace:^",
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"cleye": "^2.3.0"
|
||||
"@backstage/errors": "workspace:^",
|
||||
"cleye": "^2.3.0",
|
||||
"zod": "^3.25.76 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
|
||||
@@ -65,9 +65,9 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { accessToken, instance } = await resolveAuth(instanceFlag);
|
||||
const { accessToken, baseUrl } = await resolveAuth(instanceFlag);
|
||||
|
||||
const client = new ActionsClient(instance.baseUrl, accessToken);
|
||||
const client = new ActionsClient(baseUrl, accessToken);
|
||||
const actions = await client.listForPlugin(actionId);
|
||||
const action = actions.find(a => a.id === actionId);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
args,
|
||||
);
|
||||
|
||||
const { accessToken, pluginSources, instance } = await resolveAuth(
|
||||
const { accessToken, pluginSources, baseUrl } = await resolveAuth(
|
||||
instanceFlag,
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new ActionsClient(instance.baseUrl, accessToken);
|
||||
const client = new ActionsClient(baseUrl, accessToken);
|
||||
const actions = await client.list(pluginSources);
|
||||
|
||||
if (!actions.length) {
|
||||
|
||||
@@ -15,12 +15,10 @@
|
||||
*/
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const pluginSourcesSchema = z.array(z.string()).default([]);
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const parsed = cli(
|
||||
@@ -34,9 +32,10 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
const auth = await CliAuth.create();
|
||||
const existing = pluginSourcesSchema.parse(
|
||||
await auth.getMetadata('pluginSources'),
|
||||
);
|
||||
|
||||
if (existing.includes(pluginId)) {
|
||||
process.stderr.write(
|
||||
@@ -45,10 +44,7 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateInstanceConfig(instance.name, 'pluginSources', [
|
||||
...existing,
|
||||
pluginId,
|
||||
]);
|
||||
await auth.setMetadata('pluginSources', [...existing, pluginId]);
|
||||
|
||||
process.stdout.write(`Added plugin source "${pluginId}".\n`);
|
||||
};
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
*/
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const pluginSourcesSchema = z.array(z.string()).default([]);
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info }, undefined, args);
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const sources =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
const auth = await CliAuth.create();
|
||||
const sources = pluginSourcesSchema.parse(
|
||||
await auth.getMetadata('pluginSources'),
|
||||
);
|
||||
|
||||
if (!sources.length) {
|
||||
process.stderr.write('No plugin sources configured.\n');
|
||||
|
||||
@@ -15,12 +15,10 @@
|
||||
*/
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from '@backstage/cli-module-auth';
|
||||
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const pluginSourcesSchema = z.array(z.string()).default([]);
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const parsed = cli(
|
||||
@@ -34,17 +32,17 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
const auth = await CliAuth.create();
|
||||
const existing = pluginSourcesSchema.parse(
|
||||
await auth.getMetadata('pluginSources'),
|
||||
);
|
||||
|
||||
if (!existing.includes(pluginId)) {
|
||||
process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateInstanceConfig(
|
||||
instance.name,
|
||||
await auth.setMetadata(
|
||||
'pluginSources',
|
||||
existing.filter(s => s !== pluginId),
|
||||
);
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { ActionsClient } from './ActionsClient';
|
||||
import { httpJson } from '@backstage/cli-module-auth';
|
||||
import { httpJson } from './httpJson';
|
||||
|
||||
jest.mock('@backstage/cli-module-auth', () => ({
|
||||
jest.mock('./httpJson', () => ({
|
||||
httpJson: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { httpJson } from '@backstage/cli-module-auth';
|
||||
import { httpJson } from './httpJson';
|
||||
|
||||
export type ActionDef = {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
body: init?.body ? JSON.stringify(init.body) : undefined,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -15,99 +15,66 @@
|
||||
*/
|
||||
|
||||
import { resolveAuth } from './resolveAuth';
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
accessTokenNeedsRefresh,
|
||||
refreshAccessToken,
|
||||
getSecretStore,
|
||||
type StoredInstance,
|
||||
} from '@backstage/cli-module-auth';
|
||||
import { CliAuth } from '@backstage/cli-node';
|
||||
|
||||
jest.mock('@backstage/cli-module-auth', () => ({
|
||||
getSelectedInstance: jest.fn(),
|
||||
getInstanceConfig: jest.fn(),
|
||||
accessTokenNeedsRefresh: jest.fn(),
|
||||
refreshAccessToken: jest.fn(),
|
||||
getSecretStore: jest.fn(),
|
||||
}));
|
||||
jest.mock('@backstage/cli-node', () => {
|
||||
const actual = jest.requireActual('@backstage/cli-node');
|
||||
return {
|
||||
...actual,
|
||||
CliAuth: { create: jest.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction<
|
||||
typeof getSelectedInstance
|
||||
>;
|
||||
const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction<
|
||||
typeof getInstanceConfig
|
||||
>;
|
||||
const mockAccessTokenNeedsRefresh =
|
||||
accessTokenNeedsRefresh as jest.MockedFunction<
|
||||
typeof accessTokenNeedsRefresh
|
||||
>;
|
||||
const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction<
|
||||
typeof refreshAccessToken
|
||||
>;
|
||||
const mockGetSecretStore = getSecretStore as jest.MockedFunction<
|
||||
typeof getSecretStore
|
||||
>;
|
||||
const mockCreate = CliAuth.create as jest.MockedFunction<typeof CliAuth.create>;
|
||||
|
||||
describe('resolveAuth', () => {
|
||||
const mockInstance: StoredInstance = {
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'my-client',
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
};
|
||||
|
||||
const mockSecretStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSelectedInstance.mockResolvedValue(mockInstance);
|
||||
mockAccessTokenNeedsRefresh.mockReturnValue(false);
|
||||
mockGetSecretStore.mockResolvedValue(mockSecretStore);
|
||||
mockSecretStore.get.mockResolvedValue('test-access-token');
|
||||
mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']);
|
||||
});
|
||||
|
||||
it('resolves auth with the selected instance and stored token', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
getInstanceName: jest.fn().mockReturnValue('production'),
|
||||
getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'),
|
||||
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
|
||||
getMetadata: jest.fn().mockResolvedValue(['catalog', 'scaffolder']),
|
||||
} as unknown as CliAuth);
|
||||
|
||||
const result = await resolveAuth();
|
||||
|
||||
expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined);
|
||||
expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance);
|
||||
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
|
||||
expect(mockCreate).toHaveBeenCalledWith({ instanceName: undefined });
|
||||
expect(result).toEqual({
|
||||
instance: mockInstance,
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
instanceName: 'production',
|
||||
accessToken: 'test-access-token',
|
||||
pluginSources: ['catalog', 'scaffolder'],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes instance name flag to getSelectedInstance', async () => {
|
||||
it('passes instance name flag to CliAuth.create', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
getInstanceName: jest.fn().mockReturnValue('staging'),
|
||||
getBaseUrl: jest.fn().mockReturnValue('https://staging.example.com'),
|
||||
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
|
||||
getMetadata: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as CliAuth);
|
||||
|
||||
await resolveAuth('staging');
|
||||
|
||||
expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging');
|
||||
expect(mockCreate).toHaveBeenCalledWith({ instanceName: 'staging' });
|
||||
});
|
||||
|
||||
it('refreshes the access token when it is about to expire', async () => {
|
||||
const refreshedInstance = {
|
||||
...mockInstance,
|
||||
accessTokenExpiresAt: Date.now() + 7200_000,
|
||||
};
|
||||
mockAccessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockRefreshAccessToken.mockResolvedValue(refreshedInstance);
|
||||
|
||||
const result = await resolveAuth();
|
||||
|
||||
expect(mockRefreshAccessToken).toHaveBeenCalledWith('production');
|
||||
expect(result.instance).toBe(refreshedInstance);
|
||||
});
|
||||
|
||||
it('throws when no access token is stored', async () => {
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
it('throws when getAccessToken fails', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
getInstanceName: jest.fn().mockReturnValue('production'),
|
||||
getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'),
|
||||
getAccessToken: jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('No access token found. Run "auth login" to authenticate.'),
|
||||
),
|
||||
getMetadata: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as CliAuth);
|
||||
|
||||
await expect(resolveAuth()).rejects.toThrow(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
@@ -115,7 +82,12 @@ describe('resolveAuth', () => {
|
||||
});
|
||||
|
||||
it('returns empty plugin sources when none are configured', async () => {
|
||||
mockGetInstanceConfig.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({
|
||||
getInstanceName: jest.fn().mockReturnValue('production'),
|
||||
getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'),
|
||||
getAccessToken: jest.fn().mockResolvedValue('test-access-token'),
|
||||
getMetadata: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as CliAuth);
|
||||
|
||||
const result = await resolveAuth();
|
||||
|
||||
|
||||
@@ -14,35 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
accessTokenNeedsRefresh,
|
||||
refreshAccessToken,
|
||||
getSecretStore,
|
||||
type StoredInstance,
|
||||
} from '@backstage/cli-module-auth';
|
||||
import { CliAuth } from '@backstage/cli-node';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const pluginSourcesSchema = z.array(z.string()).default([]);
|
||||
|
||||
export async function resolveAuth(instanceFlag?: string): Promise<{
|
||||
instance: StoredInstance;
|
||||
baseUrl: string;
|
||||
instanceName: string;
|
||||
accessToken: string;
|
||||
pluginSources: string[];
|
||||
}> {
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
const auth = await CliAuth.create({ instanceName: instanceFlag });
|
||||
const accessToken = await auth.getAccessToken();
|
||||
const pluginSources = pluginSourcesSchema.parse(
|
||||
await auth.getMetadata('pluginSources'),
|
||||
);
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
|
||||
const pluginSources =
|
||||
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
return { instance, accessToken, pluginSources };
|
||||
return {
|
||||
baseUrl: auth.getBaseUrl(),
|
||||
instanceName: auth.getInstanceName(),
|
||||
accessToken,
|
||||
pluginSources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"bin": "bin/backstage-cli-module-auth",
|
||||
"files": [
|
||||
"dist",
|
||||
"bin"
|
||||
@@ -50,6 +51,5 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
},
|
||||
"bin": "bin/backstage-cli-module-auth"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,67 +5,9 @@
|
||||
```ts
|
||||
import { CliModule } from '@backstage/cli-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
const _default: CliModule;
|
||||
export default _default;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getInstanceConfig<T = unknown>(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<T | undefined>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSecretStore(): Promise<SecretStore>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function refreshAccessToken(
|
||||
instanceName: string,
|
||||
): Promise<StoredInstance>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function updateInstanceConfig(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
getInstanceByName,
|
||||
StoredInstance,
|
||||
} from '../lib/storage';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
import { getSecretStore, getAuthInstanceService } from '@internal/cli';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
@@ -321,7 +321,7 @@ async function persistInstance(options: {
|
||||
const { instanceName, backendBaseUrl, clientId, token } = options;
|
||||
const secretStore = await getSecretStore();
|
||||
await withMetadataLock(async () => {
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
const service = getAuthInstanceService(instanceName);
|
||||
await secretStore.set(service, 'accessToken', token.access_token);
|
||||
if (token.refresh_token) {
|
||||
await secretStore.set(service, 'refreshToken', token.refresh_token);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
import { getSecretStore, getAuthInstanceService } from '@internal/cli';
|
||||
import {
|
||||
removeInstance,
|
||||
withMetadataLock,
|
||||
@@ -47,7 +47,7 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
await withMetadataLock(async () => {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
const service = getAuthInstanceService(instanceName);
|
||||
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
|
||||
|
||||
if (refreshToken) {
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
*/
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
|
||||
import { getSelectedInstance } from '../lib/storage';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
@@ -37,18 +34,8 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
args,
|
||||
);
|
||||
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
const auth = await CliAuth.create({ instanceName: instanceFlag });
|
||||
const accessToken = await auth.getAccessToken();
|
||||
|
||||
process.stdout.write(`${accessToken}\n`);
|
||||
};
|
||||
|
||||
@@ -15,11 +15,8 @@
|
||||
*/
|
||||
|
||||
import { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '@backstage/cli-node';
|
||||
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
|
||||
import { httpJson } from '../lib/http';
|
||||
import { getSelectedInstance } from '../lib/storage';
|
||||
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
@@ -38,23 +35,13 @@ export default async ({ args, info }: CliCommandContext) => {
|
||||
args,
|
||||
);
|
||||
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
const auth = await CliAuth.create({ instanceName: instanceFlag });
|
||||
const accessToken = await auth.getAccessToken();
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
process.stdout.write('Refreshing access token...\n');
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
const authBase = new URL('/api/auth', instance.baseUrl)
|
||||
const authBase = new URL('/api/auth', auth.getBaseUrl())
|
||||
.toString()
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
|
||||
const userinfo = await httpJson<{ claims: { sub: string; ent: string[] } }>(
|
||||
`${authBase}/v1/userinfo`,
|
||||
{
|
||||
|
||||
@@ -52,17 +52,3 @@ export default createCliModule({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export {
|
||||
getSelectedInstance,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
type StoredInstance,
|
||||
} from './lib/storage';
|
||||
/** @public */
|
||||
export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
|
||||
/** @public */
|
||||
export { getSecretStore, type SecretStore } from './lib/secretStore';
|
||||
/** @public */
|
||||
export { httpJson, type HttpInit } from './lib/http';
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
|
||||
import { accessTokenNeedsRefresh, refreshAccessToken } from './auth';
|
||||
import * as storage from './storage';
|
||||
import * as secretStore from './secretStore';
|
||||
import * as internalCli from '@internal/cli';
|
||||
import * as http from './http';
|
||||
|
||||
jest.mock('./storage');
|
||||
jest.mock('./secretStore');
|
||||
jest.mock('@internal/cli');
|
||||
jest.mock('./http');
|
||||
|
||||
const mockStorage = storage as jest.Mocked<typeof storage>;
|
||||
const mockSecretStore = secretStore as jest.Mocked<typeof secretStore>;
|
||||
const mockInternalCli = internalCli as jest.Mocked<typeof internalCli>;
|
||||
const mockHttp = http as jest.Mocked<typeof http>;
|
||||
|
||||
describe('auth', () => {
|
||||
@@ -95,7 +95,10 @@ describe('auth', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSecretStore.getSecretStore.mockResolvedValue(mockSecretStoreInstance);
|
||||
mockInternalCli.getSecretStore.mockResolvedValue(mockSecretStoreInstance);
|
||||
mockInternalCli.getAuthInstanceService.mockImplementation(
|
||||
(name: string) => `backstage-cli:auth-instance:${name}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully refresh access token', async () => {
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
import { z } from 'zod/v3';
|
||||
import {
|
||||
StoredInstance,
|
||||
type StoredInstance,
|
||||
upsertInstance,
|
||||
withMetadataLock,
|
||||
getInstanceByName,
|
||||
} from './storage';
|
||||
import { getSecretStore } from './secretStore';
|
||||
import { getSecretStore, getAuthInstanceService } from '@internal/cli';
|
||||
import { httpJson } from './http';
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
@@ -31,12 +31,11 @@ const TokenResponseSchema = z.object({
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
|
||||
// 2 minutes before expiration
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function refreshAccessToken(
|
||||
instanceName: string,
|
||||
): Promise<StoredInstance> {
|
||||
@@ -45,7 +44,7 @@ export async function refreshAccessToken(
|
||||
return withMetadataLock(async () => {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
const service = getAuthInstanceService(instanceName);
|
||||
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
/** @public */
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
@@ -24,7 +23,6 @@ export type HttpInit = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
|
||||
@@ -21,7 +21,7 @@ jest.mock('keytar', () => {
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { getSecretStore, resetSecretStore } from './secretStore';
|
||||
import { getSecretStore, resetSecretStore } from '@internal/cli';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
getAllInstances,
|
||||
getSelectedInstance,
|
||||
getInstanceByName,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
getInstanceMetadata,
|
||||
updateInstanceMetadata,
|
||||
upsertInstance,
|
||||
removeInstance,
|
||||
setSelectedInstance,
|
||||
@@ -359,65 +359,65 @@ describe('storage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceConfig', () => {
|
||||
it('should return undefined when no config set', async () => {
|
||||
describe('getInstanceMetadata', () => {
|
||||
it('should return undefined when no metadata set', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
|
||||
const result = await getInstanceConfig('production', 'someKey');
|
||||
const result = await getInstanceMetadata('production', 'someKey');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return config value for a key', async () => {
|
||||
it('should return metadata value for a key', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'myKey', 'myValue');
|
||||
await updateInstanceMetadata('production', 'myKey', 'myValue');
|
||||
|
||||
const result = await getInstanceConfig('production', 'myKey');
|
||||
const result = await getInstanceMetadata('production', 'myKey');
|
||||
expect(result).toBe('myValue');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError for unknown instance', async () => {
|
||||
await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow(
|
||||
await expect(getInstanceMetadata('nonexistent', 'key')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInstanceConfig', () => {
|
||||
it('should set a config value', async () => {
|
||||
describe('updateInstanceMetadata', () => {
|
||||
it('should set a metadata value', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
await updateInstanceMetadata('production', 'key1', 'value1');
|
||||
|
||||
const result = await getInstanceConfig('production', 'key1');
|
||||
const result = await getInstanceMetadata('production', 'key1');
|
||||
expect(result).toBe('value1');
|
||||
});
|
||||
|
||||
it('should preserve existing config keys', async () => {
|
||||
it('should preserve existing metadata keys', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
await updateInstanceConfig('production', 'key2', 'value2');
|
||||
await updateInstanceMetadata('production', 'key1', 'value1');
|
||||
await updateInstanceMetadata('production', 'key2', 'value2');
|
||||
|
||||
const result1 = await getInstanceConfig('production', 'key1');
|
||||
const result2 = await getInstanceConfig('production', 'key2');
|
||||
const result1 = await getInstanceMetadata('production', 'key1');
|
||||
const result2 = await getInstanceMetadata('production', 'key2');
|
||||
expect(result1).toBe('value1');
|
||||
expect(result2).toBe('value2');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError for unknown instance', async () => {
|
||||
await expect(
|
||||
updateInstanceConfig('nonexistent', 'key', 'value'),
|
||||
updateInstanceMetadata('nonexistent', 'key', 'value'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('should remove instance along with its config', async () => {
|
||||
it('should remove instance along with its metadata', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
await updateInstanceConfig('production', 'key1', 'value1');
|
||||
await updateInstanceMetadata('production', 'key1', 'value1');
|
||||
await removeInstance('production');
|
||||
|
||||
const { instances } = await getAllInstances();
|
||||
expect(instances.find(i => i.name === 'production')).toBeUndefined();
|
||||
|
||||
await upsertInstance(mockInstance1);
|
||||
const result = await getInstanceConfig('production', 'key1');
|
||||
const result = await getInstanceMetadata('production', 'key1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,16 @@ import lockfile from 'proper-lockfile';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const METADATA_FILE = 'auth-instances.yaml';
|
||||
|
||||
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
|
||||
@@ -36,20 +46,9 @@ const storedInstanceSchema = z.object({
|
||||
issuedAt: z.number().int().nonnegative(),
|
||||
accessTokenExpiresAt: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const authYamlSchema = z.object({
|
||||
instances: z.array(storedInstanceSchema).default([]),
|
||||
});
|
||||
@@ -97,9 +96,11 @@ export async function getAllInstances(): Promise<{
|
||||
selected: StoredInstance | undefined;
|
||||
}> {
|
||||
const { instances } = await readAll();
|
||||
if (instances.length === 0) {
|
||||
return { instances: [], selected: undefined };
|
||||
}
|
||||
const selected = instances.find(i => i.selected) ?? instances[0];
|
||||
return {
|
||||
// Normalize selection prop
|
||||
instances: instances.map(i => ({
|
||||
...i,
|
||||
selected: i.name === selected.name,
|
||||
@@ -108,7 +109,6 @@ export async function getAllInstances(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance> {
|
||||
@@ -171,17 +171,15 @@ export async function setSelectedInstance(name: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function getInstanceConfig<T = unknown>(
|
||||
export async function getInstanceMetadata(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<T | undefined> {
|
||||
): Promise<unknown> {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
return instance.config?.[key] as T | undefined;
|
||||
return instance.metadata?.[key];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export async function updateInstanceConfig(
|
||||
export async function updateInstanceMetadata(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
@@ -194,7 +192,7 @@ export async function updateInstanceConfig(
|
||||
}
|
||||
data.instances[idx] = {
|
||||
...data.instances[idx],
|
||||
config: { ...data.instances[idx].config, [key]: value },
|
||||
metadata: { ...data.instances[idx].metadata, [key]: value },
|
||||
};
|
||||
await writeAll(data);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"commander": "^12.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"pirates": "^4.0.6",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"semver": "^7.5.3",
|
||||
"yaml": "^2.0.0",
|
||||
"zod": "^3.25.76 || ^4.0.0"
|
||||
@@ -50,8 +51,12 @@
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@types/proper-lockfile": "^4",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": "^1.15.6"
|
||||
},
|
||||
|
||||
@@ -86,6 +86,21 @@ export interface BackstagePackageJson {
|
||||
version: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class CliAuth {
|
||||
static create(options?: CliAuthCreateOptions): Promise<CliAuth>;
|
||||
getAccessToken(): Promise<string>;
|
||||
getBaseUrl(): string;
|
||||
getInstanceName(): string;
|
||||
getMetadata(key: string): Promise<unknown>;
|
||||
setMetadata(key: string, value: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CliAuthCreateOptions {
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CliCommand {
|
||||
deprecated?: boolean;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CliAuth } from './CliAuth';
|
||||
import * as storage from './storage';
|
||||
import * as secretStoreModule from './secretStore';
|
||||
import * as httpModule from './httpJson';
|
||||
|
||||
jest.mock('./storage');
|
||||
jest.mock('./secretStore');
|
||||
jest.mock('./httpJson');
|
||||
|
||||
const mockStorage = storage as jest.Mocked<typeof storage>;
|
||||
const mockSecretStoreModule = secretStoreModule as jest.Mocked<
|
||||
typeof secretStoreModule
|
||||
>;
|
||||
const mockHttp = httpModule as jest.Mocked<typeof httpModule>;
|
||||
|
||||
describe('CliAuth', () => {
|
||||
const now = Date.now();
|
||||
const mockInstance = {
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'prod-client',
|
||||
issuedAt: now,
|
||||
accessTokenExpiresAt: now + 3600_000,
|
||||
};
|
||||
|
||||
const mockSecretStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockStorage.getSelectedInstance.mockResolvedValue(mockInstance);
|
||||
mockSecretStoreModule.getSecretStore.mockResolvedValue(mockSecretStore);
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(false);
|
||||
mockSecretStore.get.mockResolvedValue('test-access-token');
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('resolves the currently selected instance by default', async () => {
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith(undefined);
|
||||
expect(auth.getInstanceName()).toBe('production');
|
||||
expect(auth.getBaseUrl()).toBe('https://backstage.example.com');
|
||||
});
|
||||
|
||||
it('resolves a named instance when specified', async () => {
|
||||
await CliAuth.create({ instanceName: 'staging' });
|
||||
|
||||
expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith('staging');
|
||||
});
|
||||
|
||||
it('throws when no instance can be found', async () => {
|
||||
mockStorage.getSelectedInstance.mockRejectedValue(
|
||||
new Error(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
),
|
||||
);
|
||||
|
||||
await expect(CliAuth.create()).rejects.toThrow(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('returns a stored access token when it is still valid', async () => {
|
||||
const auth = await CliAuth.create();
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
expect(token).toBe('test-access-token');
|
||||
expect(mockSecretStore.get).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'accessToken',
|
||||
);
|
||||
expect(mockHttp.httpJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when no access token is stored', async () => {
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the token when it is about to expire', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'refreshToken') return 'old-refresh-token';
|
||||
if (account === 'accessToken') return 'new-access-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
});
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
expect(token).toBe('new-access-token');
|
||||
expect(mockHttp.httpJson).toHaveBeenCalledWith(
|
||||
'https://backstage.example.com/api/auth/v1/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockSecretStore.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'accessToken',
|
||||
'new-access-token',
|
||||
);
|
||||
expect(mockSecretStore.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:production',
|
||||
'refreshToken',
|
||||
'new-refresh-token',
|
||||
);
|
||||
expect(mockStorage.updateInstance).toHaveBeenCalledWith('production', {
|
||||
issuedAt: expect.any(Number),
|
||||
accessTokenExpiresAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when refresh token is missing and access token has expired', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the token response is malformed', async () => {
|
||||
mockStorage.accessTokenNeedsRefresh.mockReturnValue(true);
|
||||
mockSecretStore.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'refreshToken') return 'refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
|
||||
await expect(auth.getAccessToken()).rejects.toThrow(
|
||||
'Invalid token response',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata / setMetadata', () => {
|
||||
it('returns a metadata value from the instance', async () => {
|
||||
mockStorage.getInstanceMetadata.mockResolvedValue([
|
||||
'catalog',
|
||||
'scaffolder',
|
||||
]);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const sources = await auth.getMetadata('pluginSources');
|
||||
|
||||
expect(sources).toEqual(['catalog', 'scaffolder']);
|
||||
expect(mockStorage.getInstanceMetadata).toHaveBeenCalledWith(
|
||||
'production',
|
||||
'pluginSources',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for missing metadata keys', async () => {
|
||||
mockStorage.getInstanceMetadata.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
const value = await auth.getMetadata('nonexistent');
|
||||
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes a metadata value to the instance store', async () => {
|
||||
mockStorage.updateInstanceMetadata.mockResolvedValue(undefined);
|
||||
|
||||
const auth = await CliAuth.create();
|
||||
await auth.setMetadata('pluginSources', ['catalog']);
|
||||
|
||||
expect(mockStorage.updateInstanceMetadata).toHaveBeenCalledWith(
|
||||
'production',
|
||||
'pluginSources',
|
||||
['catalog'],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type StoredInstance,
|
||||
getSelectedInstance,
|
||||
getInstanceMetadata,
|
||||
updateInstanceMetadata,
|
||||
updateInstance,
|
||||
accessTokenNeedsRefresh,
|
||||
} from './storage';
|
||||
import { getSecretStore, type SecretStore } from './secretStore';
|
||||
import { getAuthInstanceService } from './authIdentifiers';
|
||||
import { httpJson } from './httpJson';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
token_type: z.string().min(1),
|
||||
expires_in: z.number().positive().finite(),
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Options for creating a {@link CliAuth} instance.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CliAuthCreateOptions {
|
||||
/**
|
||||
* An explicit instance name to resolve. When omitted the currently
|
||||
* selected instance is used.
|
||||
*/
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages authentication state for Backstage CLI commands.
|
||||
*
|
||||
* Reads the currently selected (or explicitly named) auth instance from
|
||||
* the on-disk instance store, transparently refreshes expired access
|
||||
* tokens, and exposes helpers that other CLI modules need to talk to a
|
||||
* Backstage backend.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CliAuth {
|
||||
readonly #secretStore: SecretStore;
|
||||
#instance: StoredInstance;
|
||||
|
||||
/**
|
||||
* Resolve the current auth instance and return a ready-to-use
|
||||
* {@link CliAuth} object. Throws when no instance can be found.
|
||||
*/
|
||||
static async create(options?: CliAuthCreateOptions): Promise<CliAuth> {
|
||||
const instance = await getSelectedInstance(options?.instanceName);
|
||||
const secretStore = await getSecretStore();
|
||||
return new CliAuth(instance, secretStore);
|
||||
}
|
||||
|
||||
private constructor(instance: StoredInstance, secretStore: SecretStore) {
|
||||
this.#instance = instance;
|
||||
this.#secretStore = secretStore;
|
||||
}
|
||||
|
||||
/** Returns the name of the resolved auth instance. */
|
||||
getInstanceName(): string {
|
||||
return this.#instance.name;
|
||||
}
|
||||
|
||||
/** Returns the base URL of the resolved auth instance. */
|
||||
getBaseUrl(): string {
|
||||
return this.#instance.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid access token, refreshing it first if the current
|
||||
* token is expired or about to expire.
|
||||
*/
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (accessTokenNeedsRefresh(this.#instance)) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
|
||||
const service = getAuthInstanceService(this.#instance.name);
|
||||
const token = await this.#secretStore.get(service, 'accessToken');
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'No access token found. Run "auth login" to authenticate.',
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a per-instance metadata value previously stored by the
|
||||
* auth module (e.g. `pluginSources`).
|
||||
*/
|
||||
async getMetadata(key: string): Promise<unknown> {
|
||||
return getInstanceMetadata(this.#instance.name, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a per-instance metadata value to the on-disk instance store.
|
||||
*/
|
||||
async setMetadata(key: string, value: unknown): Promise<void> {
|
||||
return updateInstanceMetadata(this.#instance.name, key, value);
|
||||
}
|
||||
|
||||
async #refreshAccessToken(): Promise<void> {
|
||||
const service = getAuthInstanceService(this.#instance.name);
|
||||
const refreshToken =
|
||||
(await this.#secretStore.get(service, 'refreshToken')) ?? '';
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await httpJson<unknown>(
|
||||
`${this.#instance.baseUrl}/api/auth/v1/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = TokenResponseSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid token response: ${parsed.error.message}`);
|
||||
}
|
||||
const token = parsed.data;
|
||||
|
||||
await this.#secretStore.set(service, 'accessToken', token.access_token);
|
||||
if (token.refresh_token) {
|
||||
await this.#secretStore.set(service, 'refreshToken', token.refresh_token);
|
||||
}
|
||||
const issuedAt = Date.now();
|
||||
const accessTokenExpiresAt = Date.now() + token.expires_in * 1000;
|
||||
this.#instance = { ...this.#instance, issuedAt, accessTokenExpiresAt };
|
||||
await updateInstance(this.#instance.name, {
|
||||
issuedAt,
|
||||
accessTokenExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
export function getAuthInstanceService(instanceName: string): string {
|
||||
return `backstage-cli:auth-instance:${instanceName}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
/** @internal */
|
||||
export type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
body: init?.body ? JSON.stringify(init.body) : undefined,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CliAuth, type CliAuthCreateOptions } from './CliAuth';
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** @internal */
|
||||
export type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
};
|
||||
|
||||
async function loadKeytar(): Promise<typeof import('keytar') | undefined> {
|
||||
try {
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies, @backstage/no-undeclared-imports
|
||||
const keytar = require('keytar') as typeof import('keytar');
|
||||
if (keytar && typeof keytar.getPassword === 'function') {
|
||||
return keytar;
|
||||
}
|
||||
} catch {
|
||||
// keytar not available
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class KeytarSecretStore implements SecretStore {
|
||||
private readonly keytar: typeof import('keytar');
|
||||
constructor(keytar: typeof import('keytar')) {
|
||||
this.keytar = keytar;
|
||||
}
|
||||
async get(service: string, account: string): Promise<string | undefined> {
|
||||
const result = await this.keytar.getPassword(service, account);
|
||||
return result ?? undefined;
|
||||
}
|
||||
async set(service: string, account: string, secret: string): Promise<void> {
|
||||
await this.keytar.setPassword(service, account, secret);
|
||||
}
|
||||
async delete(service: string, account: string): Promise<void> {
|
||||
await this.keytar.deletePassword(service, account);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class FileSecretStore implements SecretStore {
|
||||
private readonly baseDir: string;
|
||||
constructor() {
|
||||
const root =
|
||||
process.env.XDG_DATA_HOME ||
|
||||
(process.platform === 'win32'
|
||||
? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
: path.join(os.homedir(), '.local', 'share'));
|
||||
this.baseDir = path.join(root, 'backstage-cli', 'auth-secrets');
|
||||
}
|
||||
private filePath(service: string, account: string): string {
|
||||
return path.join(
|
||||
this.baseDir,
|
||||
encodeURIComponent(service),
|
||||
`${encodeURIComponent(account)}.secret`,
|
||||
);
|
||||
}
|
||||
async get(service: string, account: string): Promise<string | undefined> {
|
||||
const file = this.filePath(service, account);
|
||||
if (!(await pathExists(file))) {
|
||||
return undefined;
|
||||
}
|
||||
return await fs.readFile(file, 'utf8');
|
||||
}
|
||||
async set(service: string, account: string, secret: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, secret, { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
async delete(service: string, account: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: SecretStore | undefined;
|
||||
|
||||
/** @internal */
|
||||
export async function getSecretStore(): Promise<SecretStore> {
|
||||
if (!singleton) {
|
||||
const keytar = await loadKeytar();
|
||||
if (keytar) {
|
||||
singleton = new KeytarSecretStore(keytar);
|
||||
} else {
|
||||
singleton = new FileSecretStore();
|
||||
}
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing purposes only)
|
||||
* @internal
|
||||
*/
|
||||
export function resetSecretStore(): void {
|
||||
singleton = undefined;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import lockfile from 'proper-lockfile';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
const METADATA_FILE = 'auth-instances.yaml';
|
||||
|
||||
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
|
||||
|
||||
const storedInstanceSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(INSTANCE_NAME_PATTERN, 'Instance name contains invalid characters'),
|
||||
baseUrl: z.string().url(),
|
||||
clientId: z.string().min(1),
|
||||
issuedAt: z.number().int().nonnegative(),
|
||||
accessTokenExpiresAt: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const authYamlSchema = z.object({
|
||||
instances: z.array(storedInstanceSchema).default([]),
|
||||
});
|
||||
|
||||
export type StoredInstance = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
clientId: string;
|
||||
issuedAt: number;
|
||||
accessTokenExpiresAt: number;
|
||||
selected?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function getMetadataFilePath(): string {
|
||||
const root =
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
(process.platform === 'win32'
|
||||
? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
: path.join(os.homedir(), '.config'));
|
||||
|
||||
return path.join(root, 'backstage-cli', METADATA_FILE);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function readAll(): Promise<{ instances: StoredInstance[] }> {
|
||||
const file = getMetadataFilePath();
|
||||
if (!(await pathExists(file))) {
|
||||
return { instances: [] };
|
||||
}
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
if (!text.trim()) {
|
||||
return { instances: [] };
|
||||
}
|
||||
try {
|
||||
const doc = YAML.parse(text);
|
||||
const parsed = authYamlSchema.safeParse(doc);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
return { instances: [] };
|
||||
} catch {
|
||||
return { instances: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAll(data: { instances: StoredInstance[] }): Promise<void> {
|
||||
const file = getMetadataFilePath();
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false });
|
||||
await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
|
||||
async function withMetadataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const file = getMetadataFilePath();
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
if (!(await pathExists(file))) {
|
||||
await fs.writeFile(file, '', { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
const release = await lockfile.lock(file, {
|
||||
retries: { retries: 5, factor: 1.5, minTimeout: 100, maxTimeout: 1000 },
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getAllInstances(): Promise<{
|
||||
instances: StoredInstance[];
|
||||
selected: StoredInstance | undefined;
|
||||
}> {
|
||||
const { instances } = await readAll();
|
||||
if (instances.length === 0) {
|
||||
return { instances: [], selected: undefined };
|
||||
}
|
||||
const selected = instances.find(i => i.selected) ?? instances[0];
|
||||
return {
|
||||
instances: instances.map(i => ({
|
||||
...i,
|
||||
selected: i.name === selected.name,
|
||||
})),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance> {
|
||||
if (instanceName) {
|
||||
return await getInstanceByName(instanceName);
|
||||
}
|
||||
const { selected } = await getAllInstances();
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getInstanceByName(name: string): Promise<StoredInstance> {
|
||||
const { instances } = await readAll();
|
||||
const instance = instances.find(i => i.name === name);
|
||||
if (!instance) {
|
||||
throw new NotFoundError(`Instance '${name}' not found`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getInstanceMetadata(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
): Promise<unknown> {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
return instance.metadata?.[key];
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function updateInstanceMetadata(
|
||||
instanceName: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
return withMetadataLock(async () => {
|
||||
const data = await readAll();
|
||||
const idx = data.instances.findIndex(i => i.name === instanceName);
|
||||
if (idx === -1) {
|
||||
throw new NotFoundError(`Instance '${instanceName}' not found`);
|
||||
}
|
||||
data.instances[idx] = {
|
||||
...data.instances[idx],
|
||||
metadata: { ...data.instances[idx].metadata, [key]: value },
|
||||
};
|
||||
await writeAll(data);
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function updateInstance(
|
||||
instanceName: string,
|
||||
updates: Partial<Pick<StoredInstance, 'issuedAt' | 'accessTokenExpiresAt'>>,
|
||||
): Promise<void> {
|
||||
return withMetadataLock(async () => {
|
||||
const data = await readAll();
|
||||
const idx = data.instances.findIndex(i => i.name === instanceName);
|
||||
if (idx === -1) {
|
||||
throw new NotFoundError(`Instance '${instanceName}' not found`);
|
||||
}
|
||||
data.instances[idx] = { ...data.instances[idx], ...updates };
|
||||
await writeAll(data);
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
|
||||
// 2 minutes before expiration
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000;
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './auth';
|
||||
export * from './cache';
|
||||
export * from './cli-module';
|
||||
export * from './concurrency';
|
||||
|
||||
@@ -2828,9 +2828,10 @@ __metadata:
|
||||
dependencies:
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/cli-module-auth": "workspace:^"
|
||||
"@backstage/cli-node": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
cleye: "npm:^2.3.0"
|
||||
zod: "npm:^3.25.76 || ^4.0.0"
|
||||
bin:
|
||||
cli-module-actions: bin/backstage-cli-module-actions
|
||||
languageName: unknown
|
||||
@@ -3153,18 +3154,24 @@ __metadata:
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@manypkg/get-packages": "npm:^1.1.3"
|
||||
"@types/proper-lockfile": "npm:^4"
|
||||
"@types/yarnpkg__lockfile": "npm:^1.1.4"
|
||||
"@yarnpkg/lockfile": "npm:^1.1.0"
|
||||
"@yarnpkg/parsers": "npm:^3.0.0"
|
||||
chalk: "npm:^4.0.0"
|
||||
commander: "npm:^12.0.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
keytar: "npm:^7.9.0"
|
||||
pirates: "npm:^4.0.6"
|
||||
proper-lockfile: "npm:^4.1.2"
|
||||
semver: "npm:^7.5.3"
|
||||
yaml: "npm:^2.0.0"
|
||||
zod: "npm:^3.25.76 || ^4.0.0"
|
||||
peerDependencies:
|
||||
"@swc/core": ^1.15.6
|
||||
dependenciesMeta:
|
||||
keytar:
|
||||
optional: true
|
||||
peerDependenciesMeta:
|
||||
"@swc/core":
|
||||
optional: true
|
||||
|
||||
Reference in New Issue
Block a user