cli-node: add CliAuth class for shared CLI authentication

Introduces a class-based authentication management API in @backstage/cli-node
that reads the on-disk instance store, transparently refreshes expired tokens,
and provides a convenient surface for other CLI modules to consume.

The split keeps filesystem-based instance selection and writes owned by
cli-module-auth, while reading and consuming the current instance is
available through CliAuth in cli-node.

Migrates cli-module-actions to use the new API and deprecates the ad-hoc
function exports from cli-module-auth.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 13:17:09 +01:00
parent df15d409a4
commit 12fa965e67
28 changed files with 866 additions and 244 deletions
+14 -31
View File
@@ -4,61 +4,44 @@
```ts
import { CliModule } from '@backstage/cli-node';
import { getSecretStore } from '@backstage/cli-node';
import { HttpInit } from '@backstage/cli-node';
import { httpJson } from '@backstage/cli-node';
import { SecretStore } from '@backstage/cli-node';
import { StoredInstance } from '@backstage/cli-node';
// @public (undocumented)
// @public @deprecated (undocumented)
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
// @public (undocumented)
const _default: CliModule;
export default _default;
// @public (undocumented)
// @public @deprecated (undocumented)
export function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined>;
// @public (undocumented)
export function getSecretStore(): Promise<SecretStore>;
export { getSecretStore };
// @public (undocumented)
// @public @deprecated (undocumented)
export function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
export { HttpInit };
// @public (undocumented)
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
export { httpJson };
// @public (undocumented)
// @public @deprecated (undocumented)
export function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
};
export { SecretStore };
// @public (undocumented)
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
export { StoredInstance };
// @public (undocumented)
export function updateInstanceConfig(
@@ -15,10 +15,7 @@
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
import { getSelectedInstance } from '../lib/storage';
import { getSecretStore } from '../lib/secretStore';
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
@@ -37,18 +34,8 @@ export default async ({ args, info }: CliCommandContext) => {
args,
);
let instance = await getSelectedInstance(instanceFlag);
if (accessTokenNeedsRefresh(instance)) {
instance = await refreshAccessToken(instance.name);
}
const secretStore = await getSecretStore();
const service = `backstage-cli:auth-instance:${instance.name}`;
const accessToken = await secretStore.get(service, 'accessToken');
if (!accessToken) {
throw new Error('No access token found. Run "auth login" to authenticate.');
}
const auth = await CliAuth.create({ instanceName: instanceFlag });
const accessToken = await auth.getAccessToken();
process.stdout.write(`${accessToken}\n`);
};
+4 -18
View File
@@ -15,11 +15,7 @@
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import { httpJson } from '../lib/http';
import { getSelectedInstance } from '../lib/storage';
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
import { getSecretStore } from '../lib/secretStore';
import { CliAuth, httpJson, type CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
@@ -38,23 +34,13 @@ export default async ({ args, info }: CliCommandContext) => {
args,
);
let instance = await getSelectedInstance(instanceFlag);
const auth = await CliAuth.create({ instanceName: instanceFlag });
const accessToken = await auth.getAccessToken();
if (accessTokenNeedsRefresh(instance)) {
process.stdout.write('Refreshing access token...\n');
instance = await refreshAccessToken(instance.name);
}
const authBase = new URL('/api/auth', instance.baseUrl)
const authBase = new URL('/api/auth', auth.baseUrl)
.toString()
.replace(/\/$/, '');
const secretStore = await getSecretStore();
const service = `backstage-cli:auth-instance:${instance.name}`;
const accessToken = await secretStore.get(service, 'accessToken');
if (!accessToken) {
throw new Error('No access token found. Run "auth login" to authenticate.');
}
const userinfo = await httpJson<{ claims: { sub: string; ent: string[] } }>(
`${authBase}/v1/userinfo`,
{
+4 -4
View File
@@ -53,16 +53,16 @@ export default createCliModule({
},
});
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export {
getSelectedInstance,
getInstanceConfig,
updateInstanceConfig,
type StoredInstance,
} from './lib/storage';
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
/** @public */
/** @public @deprecated Import from {@link @backstage/cli-node} instead. */
export { getSecretStore, type SecretStore } from './lib/secretStore';
/** @public */
/** @public @deprecated Import from {@link @backstage/cli-node} instead. */
export { httpJson, type HttpInit } from './lib/http';
+6 -9
View File
@@ -15,12 +15,8 @@
*/
import { z } from 'zod/v3';
import {
StoredInstance,
upsertInstance,
withMetadataLock,
getInstanceByName,
} from './storage';
import type { StoredInstance } from '@backstage/cli-node';
import { upsertInstance, withMetadataLock, getInstanceByName } from './storage';
import { getSecretStore } from './secretStore';
import { httpJson } from './http';
@@ -31,12 +27,13 @@ const TokenResponseSchema = z.object({
refresh_token: z.string().min(1).optional(),
});
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
// 2 minutes before expiration
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000;
}
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export async function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance> {
+1 -25
View File
@@ -14,28 +14,4 @@
* limitations under the License.
*/
import { ResponseError } from '@backstage/errors';
/** @public */
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
/** @public */
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
const res = await fetch(url, {
...init,
body: init?.body ? JSON.stringify(init.body) : undefined,
headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
});
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
return (await res.json()) as T;
}
export { httpJson, type HttpInit } from '@backstage/cli-node';
@@ -18,12 +18,8 @@ import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
/** @public */
export type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
};
export type { SecretStore } from '@backstage/cli-node';
import type { SecretStore } from '@backstage/cli-node';
async function loadKeytar(): Promise<typeof import('keytar') | undefined> {
try {
+5 -14
View File
@@ -22,6 +22,9 @@ import lockfile from 'proper-lockfile';
import YAML from 'yaml';
import { z } from 'zod/v3';
export type { StoredInstance } from '@backstage/cli-node';
import type { StoredInstance } from '@backstage/cli-node';
const METADATA_FILE = 'auth-instances.yaml';
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
@@ -39,17 +42,6 @@ const storedInstanceSchema = z.object({
config: z.record(z.string(), z.unknown()).optional(),
});
/** @public */
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
const authYamlSchema = z.object({
instances: z.array(storedInstanceSchema).default([]),
});
@@ -99,7 +91,6 @@ export async function getAllInstances(): Promise<{
const { instances } = await readAll();
const selected = instances.find(i => i.selected) ?? instances[0];
return {
// Normalize selection prop
instances: instances.map(i => ({
...i,
selected: i.name === selected.name,
@@ -108,7 +99,7 @@ export async function getAllInstances(): Promise<{
};
}
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */
export async function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance> {
@@ -171,7 +162,7 @@ export async function setSelectedInstance(name: string): Promise<void> {
});
}
/** @public */
/** @public @deprecated Use {@link @backstage/cli-node#CliAuth.getConfig} instead. */
export async function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,