feat(cli): add auth commands for OIDC login (#32920)

* feat(cli): add auth commands for OIDC login

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback

- move CIMD check before callback server start
- add try/finally for callback server cleanup
- validate URLs with human-readable errors
- deduplicate config URL candidates
- preserve selected flag on re-authentication
- delete accessToken on logout
- log token refresh to stderr in show command
- fix command descriptions to reference CIMD not DCR
- type keytar as optionalDependency, rename storage paths
- add auth-backend changeset

Signed-off-by: benjdlambert <ben@blam.sh>

* migrate auth module from yargs to cleye pattern

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback

- consolidate storage imports in auth.ts
- add withMetadataLock to setSelectedInstance
- skip file permission tests on Windows
- clarify changeset endpoint path

Signed-off-by: benjdlambert <ben@blam.sh>

* address review feedback from Rugvip and Copilot

- use stdout for user-facing messages instead of stderr
- remove clientSecret remnants from logout
- make refresh_token optional in token response schema
- add timeout to CIMD metadata fetch
- pass same state to callback server and authorize URL
- remove inaccurate test comment

Signed-off-by: benjdlambert <ben@blam.sh>

* validate state in callback server, add CIMD endpoint tests

- localServer now validates the OAuth state parameter in the request
  handler and returns 400 on mismatch
- Added tests for the CIMD metadata endpoint in OidcRouter covering
  both disabled and enabled cases

Signed-off-by: benjdlambert <ben@blam.sh>

* revert validateRequest to use Zod error details

Signed-off-by: benjdlambert <ben@blam.sh>

* fix callback server hanging by closing keep-alive connections

Signed-off-by: benjdlambert <ben@blam.sh>

* rename secret store service prefix to backstage-cli:auth-instance

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-10 14:28:25 +01:00
committed by GitHub
parent d056002e64
commit d0f4cd215b
29 changed files with 3314 additions and 1 deletions
+80
View File
@@ -12,6 +12,7 @@ Options:
-h, --help
Commands:
auth [command]
build-workspace
config [command]
config:check
@@ -30,6 +31,85 @@ Commands:
versions:migrate
```
### `backstage-cli auth`
```
Usage: backstage-cli auth [options] [command] [command]
Options:
-h, --help
Commands:
help [command]
list
login
logout
print-token
select
show
```
### `backstage-cli auth list`
```
Usage: backstage-cli auth list
Options:
-h, --help
```
### `backstage-cli auth login`
```
Usage: backstage-cli auth login
Options:
--backend-url <string>
--instance <string>
--no-browser
-h, --help
```
### `backstage-cli auth logout`
```
Usage: backstage-cli auth logout
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth print-token`
```
Usage: backstage-cli auth print-token
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth select`
```
Usage: backstage-cli auth select
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth show`
```
Usage: backstage-cli auth show
Options:
--instance <string>
-h, --help
```
### `backstage-cli build-workspace`
```
+5
View File
@@ -120,6 +120,7 @@
"postcss": "^8.1.0",
"postcss-import": "^16.1.0",
"process": "^0.11.10",
"proper-lockfile": "^4.1.2",
"raw-loader": "^4.0.2",
"react-dev-utils": "^12.0.0-next.60",
"react-refresh": "^0.17.0",
@@ -175,6 +176,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^22.13.14",
"@types/npm-packlist": "^3.0.0",
"@types/proper-lockfile": "^4",
"@types/recursive-readdir": "^2.2.0",
"@types/rollup-plugin-peer-deps-external": "^2.2.0",
"@types/rollup-plugin-postcss": "^3.1.4",
@@ -196,6 +198,9 @@
"webpack": "~5.105.0",
"webpack-dev-server": "^5.0.0"
},
"optionalDependencies": {
"keytar": "^7.9.0"
},
"peerDependencies": {
"@jest/environment-jsdom-abstract": "^30.0.0",
"@module-federation/enhanced": "^0.21.6",
+1
View File
@@ -28,5 +28,6 @@ import { CliInitializer } from './wiring/CliInitializer';
initializer.add(import('./modules/new'));
initializer.add(import('./modules/test'));
initializer.add(import('./modules/translations'));
initializer.add(import('./modules/auth'));
await initializer.run();
})();
@@ -0,0 +1,33 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
import { getAllInstances } from '../lib/storage';
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const { instances, selected } = await getAllInstances();
if (!instances.length) {
process.stderr.write('No instances found\n');
return;
}
for (const inst of instances) {
const mark = inst.name === selected?.name ? '* ' : ' ';
process.stdout.write(`${mark}${inst.name} - ${inst.baseUrl}\n`);
}
};
@@ -0,0 +1,383 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
import { startCallbackServer } from '../lib/localServer';
import { spawn } from 'node:child_process';
import { challengeFromVerifier, generateVerifier } from '../lib/pkce';
import { httpJson } from '../lib/http';
import {
upsertInstance,
withMetadataLock,
getAllInstances,
getInstanceByName,
StoredInstance,
} from '../lib/storage';
import { getSecretStore } from '../lib/secretStore';
import crypto from 'node:crypto';
import fs from 'fs-extra';
import path from 'node:path';
import glob from 'glob';
import YAML from 'yaml';
import inquirer from 'inquirer';
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
export default async ({ args, info }: CommandContext) => {
const {
flags: { backendUrl, noBrowser, instance: instanceFlag },
} = cli(
{
help: info,
flags: {
backendUrl: { type: String, description: 'Backend base URL' },
noBrowser: {
type: Boolean,
description: 'Do not open browser automatically',
},
instance: {
type: String,
description: 'Name for this instance (used by other auth commands)',
},
},
},
undefined,
args,
);
const { instances, selected } = await getAllInstances();
let backendBaseUrl: string;
let instanceName: string;
if (instanceFlag) {
instanceName = instanceFlag;
const targetInstance = instances.find(i => i.name === instanceFlag);
if (targetInstance) {
backendBaseUrl = normalizeUrl(backendUrl) ?? targetInstance.baseUrl;
} else {
backendBaseUrl = normalizeUrl(backendUrl) ?? (await pickBaseUrl());
}
} else if (backendUrl) {
backendBaseUrl = normalizeUrl(backendUrl);
instanceName = deriveInstanceName(backendBaseUrl);
} else if (instances.length > 0) {
const choice = await promptForInstance(instances, selected);
if (choice === '__new__') {
backendBaseUrl = await pickBaseUrl();
instanceName = deriveInstanceName(backendBaseUrl);
} else {
const targetInstance = instances.find(i => i.name === choice);
if (!targetInstance) {
throw new Error('Instance not found');
}
backendBaseUrl = targetInstance.baseUrl;
instanceName = targetInstance.name;
}
} else {
backendBaseUrl = await pickBaseUrl();
instanceName = deriveInstanceName(backendBaseUrl);
}
const authBaseUrl = `${backendBaseUrl}/api/auth`;
const clientId = `${authBaseUrl}/.well-known/oauth-client/cli.json`;
const metadataResponse = await fetch(clientId, {
signal: AbortSignal.timeout(30_000),
});
if (!metadataResponse.ok) {
throw new Error(
`Server does not support CLI authentication. Ensure CIMD is enabled on the backend.`,
);
}
const { verifier, challenge, state } = createPkceState();
const callback = await startCallbackServer({ state });
try {
const authorizeUrl = buildAuthorizeUrl({
authBaseUrl,
clientId,
redirectUri: callback.url,
state,
challenge,
});
await openBrowserOrPrint(authorizeUrl, noBrowser);
const code = await waitForAuthorizationCode(callback, state);
const token = await exchangeAuthorizationCode({
authBaseUrl,
code,
redirectUri: callback.url,
verifier,
});
await persistInstance({
instanceName,
backendBaseUrl,
clientId,
token,
});
process.stdout.write('Login successful\n');
} finally {
await callback.close();
}
};
async function promptForInstance(
instances: StoredInstance[],
selected: StoredInstance | undefined,
): Promise<string> {
const choices = instances.map(i => ({
name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`,
value: i.name,
}));
choices.push({
name: 'Add new instance...',
value: '__new__',
});
const { choice } = await inquirer.prompt<{ choice: string }>([
{
type: 'list',
name: 'choice',
message: 'Select instance to authenticate:',
choices,
default: selected?.name ?? '__new__',
},
]);
return choice;
}
async function pickBaseUrl() {
const cwd = process.cwd();
const candidates: Array<{ url: string; file: string }> = [];
const patterns = [
'app-config.yaml',
'app-config.*.yaml',
'packages/*/app-config.yaml',
'packages/*/app-config.*.yaml',
];
const files = patterns.flatMap(p => glob.sync(p, { cwd, nodir: true }));
for (const file of files) {
try {
const content = await fs.readFile(path.resolve(cwd, file), 'utf8');
const doc = YAML.parse(content);
const url = doc?.backend?.baseUrl as string | undefined;
if (url) {
candidates.push({ url: normalizeUrl(url), file });
}
} catch {
// ignore parse errors
}
}
const list = [...new Map(candidates.map(c => [c.url, c])).values()];
if (list.length === 0) {
const { manual } = await inquirer.prompt<{ manual: string }>([
{ type: 'input', name: 'manual', message: 'Enter backend base URL' },
]);
return normalizeUrl(manual);
}
if (list.length === 1) {
return list[0].url;
}
const { picked } = await inquirer.prompt<{ picked: string }>([
{
type: 'list',
name: 'picked',
message: 'Select backend base URL',
choices: [
...list.map(e => ({ name: `${e.url} (${e.file})`, value: e.url })),
{ name: 'Enter manually', value: '__manual__' },
],
},
]);
if (picked === '__manual__') {
const { manual } = await inquirer.prompt<{ manual: string }>([
{ type: 'input', name: 'manual', message: 'Enter backend base URL' },
]);
return normalizeUrl(manual);
}
return picked;
}
function normalizeUrl(u: string): string;
function normalizeUrl(u: string | undefined): string | undefined;
function normalizeUrl(u: string | undefined): string | undefined {
if (u === undefined) {
return undefined;
}
try {
const url = new URL(u);
return url.toString().replace(/\/$/, '');
} catch {
throw new Error(`'${u}' is not a valid URL`);
}
}
function deriveInstanceName(url: string): string {
return new URL(url).host;
}
function createPkceState() {
const verifier = generateVerifier();
const challenge = challengeFromVerifier(verifier);
const state = cryptoRandom();
return { verifier, challenge, state };
}
function buildAuthorizeUrl(options: {
authBaseUrl: string;
clientId: string;
redirectUri: string;
state: string;
challenge: string;
}): string {
const { authBaseUrl, clientId, redirectUri, state, challenge } = options;
const authorize = new URL(`${authBaseUrl}/v1/authorize`);
authorize.searchParams.set('client_id', clientId);
authorize.searchParams.set('redirect_uri', redirectUri);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('scope', 'openid offline_access');
authorize.searchParams.set('state', state);
authorize.searchParams.set('code_challenge', challenge);
authorize.searchParams.set('code_challenge_method', 'S256');
return authorize.toString();
}
async function openBrowserOrPrint(url: string, noBrowser?: boolean) {
if (noBrowser) {
process.stdout.write(`Open this URL to continue: ${url}\n`);
} else {
process.stdout.write(`Opening the following URL: ${url}\n`);
openInBrowser(url);
}
}
async function waitForAuthorizationCode(
callback: Awaited<ReturnType<typeof startCallbackServer>>,
expectedState: string,
) {
const { code, state } = await callback.waitForCode();
if (state !== expectedState) {
throw new Error('State mismatch');
}
return code;
}
async function exchangeAuthorizationCode(options: {
authBaseUrl: string;
code: string;
redirectUri: string;
verifier: string;
}) {
const { authBaseUrl, code, redirectUri, verifier } = options;
return await httpJson<{
access_token: string;
token_type: string;
expires_in: number;
id_token?: string;
refresh_token?: string;
}>(`${authBaseUrl}/v1/token`, {
method: 'POST',
body: {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: verifier,
},
signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS),
});
}
async function persistInstance(options: {
instanceName: string;
backendBaseUrl: string;
clientId: string;
token: { access_token: string; refresh_token?: string; expires_in: number };
}) {
const { instanceName, backendBaseUrl, clientId, token } = options;
const secretStore = await getSecretStore();
await withMetadataLock(async () => {
const service = `backstage-cli:auth-instance:${instanceName}`;
await secretStore.set(service, 'accessToken', token.access_token);
if (token.refresh_token) {
await secretStore.set(service, 'refreshToken', token.refresh_token);
} else {
process.stderr.write(
'Warning: No refresh token received. You will need to re-authenticate when the access token expires.\n',
);
}
let existing: StoredInstance | undefined;
try {
existing = await getInstanceByName(instanceName);
} catch {
// new instance
}
await upsertInstance({
name: instanceName,
baseUrl: backendBaseUrl,
clientId,
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
selected: existing?.selected,
});
});
}
function cryptoRandom(): string {
return crypto.randomBytes(32).toString('hex');
}
// The react-dev-utils/openBrowser breaks the login URL by encoding the URL parameters again
export function openInBrowser(url: string): void {
const handleError = (error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown error';
process.stderr.write(
`Warning: Failed to open browser automatically: ${message}\n`,
);
process.stderr.write(`Please open this URL manually: ${url}\n`);
};
const spawnOpts = { detached: true, stdio: 'ignore' } as const;
let child;
try {
if (process.platform === 'darwin') {
child = spawn('open', [url], spawnOpts);
} else if (process.platform === 'win32') {
child = spawn(
'powershell',
['-Command', `Start-Process '${url.replace(/'/g, "''")}'`],
spawnOpts,
);
} else {
child = spawn('xdg-open', [url], spawnOpts);
}
child.unref();
child.on('error', handleError);
} catch (error) {
handleError(error);
}
}
@@ -0,0 +1,77 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
import { getSecretStore } from '../lib/secretStore';
import {
removeInstance,
withMetadataLock,
getInstanceByName,
} from '../lib/storage';
import { httpJson } from '../lib/http';
import { pickInstance } from '../lib/prompt';
export default async ({ args, info }: CommandContext) => {
const {
flags: { instance: instanceFlag },
} = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to log out',
},
},
},
undefined,
args,
);
const { name: instanceName } = await pickInstance(instanceFlag);
await withMetadataLock(async () => {
const instance = await getInstanceByName(instanceName);
const secretStore = await getSecretStore();
const service = `backstage-cli:auth-instance:${instanceName}`;
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
if (refreshToken) {
try {
const authBaseUrl = new URL('/api/auth', instance.baseUrl)
.toString()
.replace(/\/$/, '');
await httpJson(`${authBaseUrl}/v1/revoke`, {
method: 'POST',
body: {
token: refreshToken,
token_type_hint: 'refresh_token',
},
signal: AbortSignal.timeout(30_000),
});
} catch {
// ignore errors per RFC 7009
}
}
await secretStore.delete(service, 'accessToken');
await secretStore.delete(service, 'refreshToken');
await removeInstance(instance.name);
});
process.stdout.write('Logged out\n');
};
@@ -0,0 +1,54 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
import { getSelectedInstance } from '../lib/storage';
import { getSecretStore } from '../lib/secretStore';
export default async ({ args, info }: CommandContext) => {
const {
flags: { instance: instanceFlag },
} = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
},
},
undefined,
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.');
}
process.stdout.write(`${accessToken}\n`);
};
@@ -0,0 +1,43 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
import { setSelectedInstance } from '../lib/storage';
import { pickInstance } from '../lib/prompt';
export default async ({ args, info }: CommandContext) => {
const {
flags: { instance: instanceFlag },
} = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to select',
},
},
},
undefined,
args,
);
const instance = await pickInstance(instanceFlag);
await setSelectedInstance(instance.name);
process.stderr.write(`Selected instance '${instance.name}'\n`);
};
@@ -0,0 +1,72 @@
/*
* 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 { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
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 }: CommandContext) => {
const {
flags: { instance: instanceFlag },
} = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to show',
},
},
},
undefined,
args,
);
let instance = await getSelectedInstance(instanceFlag);
if (accessTokenNeedsRefresh(instance)) {
process.stdout.write('Refreshing access token...\n');
instance = await refreshAccessToken(instance.name);
}
const authBase = new URL('/api/auth', instance.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`,
{
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(30_000),
},
);
process.stdout.write(`User: ${userinfo.claims.sub}\n`);
process.stdout.write(`\n`);
process.stdout.write(`Ownership:\n`);
for (const ent of userinfo.claims.ent ?? []) {
process.stdout.write(` - ${ent}\n`);
}
};
+53
View File
@@ -0,0 +1,53 @@
/*
* 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 { createCliPlugin } from '../../wiring/factory';
export default createCliPlugin({
pluginId: 'auth',
init: async reg => {
reg.addCommand({
path: ['auth', 'login'],
description: 'Log in the CLI to a Backstage instance',
execute: { loader: () => import('./commands/login') },
});
reg.addCommand({
path: ['auth', 'logout'],
description: 'Log out the CLI and clear stored credentials',
execute: { loader: () => import('./commands/logout') },
});
reg.addCommand({
path: ['auth', 'show'],
description: 'Show details of an authenticated instance',
execute: { loader: () => import('./commands/show') },
});
reg.addCommand({
path: ['auth', 'list'],
description: 'List authenticated instances',
execute: { loader: () => import('./commands/list') },
});
reg.addCommand({
path: ['auth', 'print-token'],
description: 'Print an access token to stdout (auto-refresh if needed)',
execute: { loader: () => import('./commands/printToken') },
});
reg.addCommand({
path: ['auth', 'select'],
description: 'Select the default instance',
execute: { loader: () => import('./commands/select') },
});
},
});
@@ -0,0 +1,336 @@
/*
* 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 { accessTokenNeedsRefresh, refreshAccessToken } from './auth';
import * as storage from './storage';
import * as secretStore from './secretStore';
import * as http from './http';
jest.mock('./storage');
jest.mock('./secretStore');
jest.mock('./http');
const mockStorage = storage as jest.Mocked<typeof storage>;
const mockSecretStore = secretStore as jest.Mocked<typeof secretStore>;
const mockHttp = http as jest.Mocked<typeof http>;
describe('auth', () => {
describe('accessTokenNeedsRefresh', () => {
it('should return true if token expires within 2 minutes', () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client',
issuedAt: now,
accessTokenExpiresAt: now + 60_000, // 1 minute from now
};
expect(accessTokenNeedsRefresh(instance)).toBe(true);
});
it('should return true if token has already expired', () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000, // expired 1 minute ago
};
expect(accessTokenNeedsRefresh(instance)).toBe(true);
});
it('should return false if token is valid for more than 2 minutes', () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client',
issuedAt: now,
accessTokenExpiresAt: now + 5 * 60_000, // 5 minutes from now
};
expect(accessTokenNeedsRefresh(instance)).toBe(false);
});
it('should return true at exactly 2 minutes before expiration', () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client',
issuedAt: now,
accessTokenExpiresAt: now + 2 * 60_000, // exactly 2 minutes from now
};
expect(accessTokenNeedsRefresh(instance)).toBe(true);
});
});
describe('refreshAccessToken', () => {
const mockSecretStoreInstance = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockSecretStore.getSecretStore.mockResolvedValue(mockSecretStoreInstance);
});
it('should successfully refresh access token', async () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client-id',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000,
};
mockStorage.withMetadataLock.mockImplementation(
async (fn: () => Promise<any>) => fn(),
);
mockStorage.getInstanceByName.mockResolvedValue(instance);
mockSecretStoreInstance.get.mockImplementation(
async (_service: string, account: string) => {
if (account === 'clientSecret') return 'test-secret';
if (account === 'refreshToken') return 'old-refresh-token';
return undefined;
},
);
const tokenResponse = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
};
mockHttp.httpJson.mockResolvedValue(tokenResponse);
mockStorage.upsertInstance.mockResolvedValue();
const result = await refreshAccessToken('test');
expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('test');
expect(mockSecretStoreInstance.get).toHaveBeenCalledWith(
'backstage-cli:auth-instance:test',
'refreshToken',
);
expect(mockHttp.httpJson).toHaveBeenCalledWith(
'http://localhost:7007/api/auth/v1/token',
{
signal: expect.any(AbortSignal),
method: 'POST',
body: {
grant_type: 'refresh_token',
refresh_token: 'old-refresh-token',
},
},
);
expect(mockSecretStoreInstance.set).toHaveBeenCalledWith(
'backstage-cli:auth-instance:test',
'accessToken',
'new-access-token',
);
expect(mockSecretStoreInstance.set).toHaveBeenCalledWith(
'backstage-cli:auth-instance:test',
'refreshToken',
'new-refresh-token',
);
expect(mockStorage.upsertInstance).toHaveBeenCalled();
expect(result.accessTokenExpiresAt).toBeGreaterThan(now);
});
it('should throw error if refresh token is missing', async () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client-id',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000,
};
mockStorage.withMetadataLock.mockImplementation(
async (fn: () => Promise<any>) => fn(),
);
mockStorage.getInstanceByName.mockResolvedValue(instance);
mockSecretStoreInstance.get.mockResolvedValue(undefined);
await expect(refreshAccessToken('test')).rejects.toThrow(
'Access token is expired and no refresh token is available',
);
});
it('should use metadata lock during refresh', async () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client-id',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000,
};
let lockAcquired = false;
mockStorage.withMetadataLock.mockImplementation(
async (fn: () => Promise<any>) => {
lockAcquired = true;
return fn();
},
);
mockStorage.getInstanceByName.mockResolvedValue(instance);
mockSecretStoreInstance.get.mockImplementation(
async (_service: string, account: string) => {
if (account === 'clientSecret') return 'test-secret';
if (account === 'refreshToken') return 'refresh-token';
return undefined;
},
);
const tokenResponse = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
};
mockHttp.httpJson.mockResolvedValue(tokenResponse);
mockStorage.upsertInstance.mockResolvedValue();
await refreshAccessToken('test');
expect(lockAcquired).toBe(true);
expect(mockStorage.withMetadataLock).toHaveBeenCalled();
});
it('should handle HTTP and network errors during refresh', async () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client-id',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000,
};
const errorCases = [
new Error('Request failed with 401 Unauthorized'),
new Error('Network error'),
];
for (const error of errorCases) {
mockStorage.withMetadataLock.mockImplementation(
async (fn: () => Promise<any>) => fn(),
);
mockStorage.getInstanceByName.mockResolvedValue(instance);
mockSecretStoreInstance.get.mockImplementation(
async (_service: string, account: string) => {
if (account === 'clientSecret') return 'test-secret';
if (account === 'refreshToken') return 'refresh-token';
return undefined;
},
);
mockHttp.httpJson.mockRejectedValue(error);
await expect(refreshAccessToken('test')).rejects.toThrow(error.message);
}
});
it('should validate token response and reject malformed responses', async () => {
const now = Date.now();
const instance = {
name: 'test',
baseUrl: 'http://localhost:7007',
clientId: 'test-client-id',
issuedAt: now - 3600_000,
accessTokenExpiresAt: now - 60_000,
};
mockStorage.withMetadataLock.mockImplementation(
async (fn: () => Promise<any>) => fn(),
);
mockStorage.getInstanceByName.mockResolvedValue(instance);
mockSecretStoreInstance.get.mockImplementation(
async (_service: string, account: string) => {
if (account === 'clientSecret') return 'test-secret';
if (account === 'refreshToken') return 'refresh-token';
return undefined;
},
);
// Test missing access_token
mockHttp.httpJson.mockResolvedValue({
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
} as any);
await expect(refreshAccessToken('test')).rejects.toThrow(
'Invalid token response',
);
await expect(refreshAccessToken('test')).rejects.toThrow('access_token');
// Test missing expires_in
mockHttp.httpJson.mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token',
} as any);
await expect(refreshAccessToken('test')).rejects.toThrow(
'Invalid token response',
);
await expect(refreshAccessToken('test')).rejects.toThrow('expires_in');
// Test missing refresh_token still succeeds and preserves existing token
mockHttp.httpJson.mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
} as any);
await expect(refreshAccessToken('test')).resolves.toBeDefined();
// Test invalid expires_in (non-positive)
mockHttp.httpJson.mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 0,
refresh_token: 'new-refresh-token',
} as any);
await expect(refreshAccessToken('test')).rejects.toThrow(
'Invalid token response',
);
await expect(refreshAccessToken('test')).rejects.toThrow('expires_in');
});
});
});
+84
View File
@@ -0,0 +1,84 @@
/*
* 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 { z } from 'zod';
import {
StoredInstance,
upsertInstance,
withMetadataLock,
getInstanceByName,
} from './storage';
import { getSecretStore } from './secretStore';
import { httpJson } from './http';
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(),
});
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
}
export async function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance> {
const secretStore = await getSecretStore();
return withMetadataLock(async () => {
const instance = await getInstanceByName(instanceName);
const service = `backstage-cli:auth-instance:${instanceName}`;
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
if (!refreshToken) {
throw new Error(
'Access token is expired and no refresh token is available',
);
}
const response = await httpJson<unknown>(
`${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 secretStore.set(service, 'accessToken', token.access_token);
if (token.refresh_token) {
await secretStore.set(service, 'refreshToken', token.refresh_token);
}
const newInstance = {
...instance,
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
};
await upsertInstance(newInstance);
return newInstance;
});
}
@@ -0,0 +1,269 @@
/*
* 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 fetch from 'cross-fetch';
import { httpJson } from './http';
jest.mock('cross-fetch');
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
describe('http', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('httpJson', () => {
it('should make successful GET request and parse JSON', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ data: 'test' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await httpJson('https://example.com/api');
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
body: undefined,
}),
);
expect(result).toEqual({ data: 'test' });
});
it('should make POST request with JSON body', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ success: true }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const body = { username: 'test', password: 'secret' };
const result = await httpJson('https://example.com/api', {
method: 'POST',
body,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
}),
);
expect(result).toEqual({ success: true });
});
it('should include and merge custom headers', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ data: 'test' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
// Test custom headers without body
await httpJson('https://example.com/api', {
headers: {
Authorization: 'Bearer token',
'X-Custom': 'value',
},
});
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
headers: {
Authorization: 'Bearer token',
'X-Custom': 'value',
},
}),
);
// Test merging headers with content-type when body is present
await httpJson('https://example.com/api', {
method: 'POST',
body: { data: 'test' },
headers: {
Authorization: 'Bearer token',
},
});
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json',
},
}),
);
});
it('should throw ResponseError for non-ok responses', async () => {
const errorCases = [
{ status: 404, statusText: 'Not Found' },
{ status: 401, statusText: 'Unauthorized' },
{ status: 500, statusText: 'Internal Server Error' },
];
for (const { status, statusText } of errorCases) {
const mockResponse = {
ok: false,
status,
statusText,
url: 'https://example.com/api',
text: jest.fn().mockResolvedValue('Error'),
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(httpJson('https://example.com/api')).rejects.toThrow(
`Request failed with ${status} ${statusText}`,
);
}
});
it('should pass through abort signal from caller', async () => {
const abortController = new AbortController();
let rejectFn: (error: Error) => void;
const mockResponse = new Promise((_, reject) => {
rejectFn = reject;
setTimeout(() => {
reject(new Error('Request should have been aborted'));
}, 60000); // 60 seconds
});
mockFetch.mockImplementation((_url, options) => {
const signal = options?.signal as AbortSignal;
signal?.addEventListener('abort', () => {
rejectFn(new Error('The operation was aborted'));
});
return mockResponse as any;
});
const requestPromise = httpJson('https://example.com/api', {
signal: abortController.signal,
});
// Abort the request
abortController.abort();
await expect(requestPromise).rejects.toThrow('The operation was aborted');
});
it('should handle JSON parsing errors gracefully', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
};
mockFetch.mockResolvedValue(mockResponse as any);
await expect(httpJson('https://example.com/api')).rejects.toThrow(
'Invalid JSON',
);
});
it('should support different HTTP methods', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ success: true }),
};
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) {
mockFetch.mockResolvedValue(mockResponse as any);
await httpJson('https://example.com/api', { method });
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
method,
}),
);
}
});
it('should handle various response body types', async () => {
const testCases = [
{ body: null, expected: null },
{ body: [1, 2, 3], expected: [1, 2, 3] },
{ body: { data: 'test' }, expected: { data: 'test' } },
];
for (const { body, expected } of testCases) {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue(body),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await httpJson('https://example.com/api');
expect(result).toEqual(expected);
}
});
it('should handle network errors', async () => {
const networkError = new Error('Network error');
mockFetch.mockRejectedValue(networkError);
await expect(httpJson('https://example.com/api')).rejects.toThrow(
'Network error',
);
});
it('should use custom abort signal if provided', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ data: 'test' }),
};
mockFetch.mockResolvedValue(mockResponse as any);
const customController = new AbortController();
await httpJson('https://example.com/api', {
signal: customController.signal,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});
it('should handle malformed URLs gracefully', async () => {
const networkError = new TypeError('Failed to parse URL');
mockFetch.mockRejectedValue(networkError);
await expect(httpJson('not-a-valid-url')).rejects.toThrow();
});
it('should handle very large response bodies', async () => {
const largeData = { items: Array(10000).fill({ data: 'x'.repeat(100) }) };
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue(largeData),
};
mockFetch.mockResolvedValue(mockResponse as any);
const result = await httpJson('https://example.com/api');
expect(result).toEqual(largeData);
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/*
* 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 fetch from 'cross-fetch';
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;
}
@@ -0,0 +1,65 @@
/*
* 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 { startCallbackServer } from './localServer';
describe('localServer', () => {
it('should start on port 8055, handle requests, and resolve the code', async () => {
const { url, waitForCode, close } = await startCallbackServer({
state: 'test-state',
});
expect(url).toBe('http://127.0.0.1:8055/callback');
// 404 for non-callback paths
const notFoundResponse = await fetch(
url.replace('/callback', '/other-path'),
);
expect(notFoundResponse.status).toBe(404);
// 400 for missing code
const missingCodeResponse = await fetch(`${url}?state=test-state`);
expect(missingCodeResponse.status).toBe(400);
expect(await missingCodeResponse.text()).toBe('Missing code');
// 400 for mismatched state
const mismatchResponse = await fetch(
`${url}?code=test-code&state=wrong-state`,
);
expect(mismatchResponse.status).toBe(400);
expect(await mismatchResponse.text()).toBe('State mismatch');
// 200 for valid callback with matching state
const codePromise = waitForCode();
const specialCode = 'test-code+with/special=chars';
const successResponse = await fetch(
`${url}?code=${encodeURIComponent(
specialCode,
)}&state=${encodeURIComponent('test-state')}`,
);
expect(successResponse.status).toBe(200);
expect(await successResponse.text()).toBe('You may now close this window.');
expect(successResponse.headers.get('content-type')).toBe(
'text/plain; charset=utf-8',
);
const result = await codePromise;
expect(result.code).toBe(specialCode);
expect(result.state).toBe('test-state');
await close();
});
});
@@ -0,0 +1,98 @@
/*
* 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 http from 'node:http';
import { URL } from 'node:url';
const CALLBACK_PORT = 8055;
export async function startCallbackServer(options: { state: string }): Promise<{
url: string;
waitForCode: () => Promise<{ code: string; state?: string }>;
close: () => Promise<void>;
}> {
const server = http.createServer();
let resolveResult:
| ((v: { code: string; state?: string }) => void)
| undefined;
const resultPromise = new Promise<{ code: string; state?: string }>(
resolve => {
resolveResult = resolve;
},
);
server.on('request', (req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad Request');
return;
}
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname !== '/callback') {
res.statusCode = 404;
res.end('Not Found');
return;
}
const code = u.searchParams.get('code') ?? undefined;
const state = u.searchParams.get('state') ?? undefined;
if (!code) {
res.statusCode = 400;
res.end('Missing code');
return;
}
if (state !== options.state) {
res.statusCode = 400;
res.end('State mismatch');
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('You may now close this window.');
resolveResult?.({ code, state });
});
const port = await new Promise<number>((resolve, reject) => {
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
reject(
new Error(
`Port ${CALLBACK_PORT} is already in use. Close the application using it and try again.`,
),
);
} else {
reject(err);
}
});
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'object' && address && 'port' in address) {
resolve(address.port);
} else {
reject(new Error('Failed to bind local server'));
}
});
});
return {
url: `http://127.0.0.1:${port}/callback`,
waitForCode: () => resultPromise,
close: async () => {
server.closeAllConnections();
return new Promise<void>(resolve => server.close(() => resolve()));
},
};
}
@@ -0,0 +1,178 @@
/*
* 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 crypto from 'node:crypto';
import { generateVerifier, challengeFromVerifier } from './pkce';
describe('pkce', () => {
describe('generateVerifier', () => {
it('should generate verifiers with proper encoding and length', () => {
// Test default length
const defaultVerifier = generateVerifier();
expect(defaultVerifier).toBeDefined();
expect(typeof defaultVerifier).toBe('string');
expect(defaultVerifier.length).toBeGreaterThan(0);
// Test custom lengths
const shortVerifier = generateVerifier(32);
const longVerifier = generateVerifier(96);
expect(shortVerifier).toBeDefined();
expect(longVerifier).toBeDefined();
expect(shortVerifier.length).toBeGreaterThan(0);
expect(longVerifier.length).toBeGreaterThan(shortVerifier.length);
// Test base64url encoding (no padding, proper characters)
const verifier = generateVerifier();
expect(verifier).not.toContain('=');
expect(verifier).not.toContain('+');
expect(verifier).not.toContain('/');
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
// Test uniqueness
const verifier1 = generateVerifier();
const verifier2 = generateVerifier();
expect(verifier1).not.toBe(verifier2);
});
it('should enforce minimum and maximum length constraints', () => {
// Test minimum length enforcement
const minVerifier = generateVerifier(10); // Less than minimum
expect(minVerifier).toBeDefined();
expect(minVerifier.length).toBeGreaterThanOrEqual(43); // 32 bytes = 43 base64url chars
// Test maximum length enforcement
const maxVerifier = generateVerifier(200); // More than maximum
expect(maxVerifier).toBeDefined();
expect(maxVerifier.length).toBeLessThanOrEqual(128); // 96 bytes = 128 base64url chars
});
it('should produce consistent results for same byte sequence', () => {
// Mock crypto.randomBytes to return predictable values
const mockBytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
jest.spyOn(crypto, 'randomBytes').mockImplementation(_count => mockBytes);
const verifier1 = generateVerifier(8);
const verifier2 = generateVerifier(8);
expect(verifier1).toBe(verifier2);
(crypto.randomBytes as jest.Mock).mockRestore();
});
});
describe('challengeFromVerifier', () => {
it('should generate challenges with proper encoding and consistency', () => {
const verifier = 'test-verifier-string';
const challenge = challengeFromVerifier(verifier);
// Basic properties
expect(challenge).toBeDefined();
expect(typeof challenge).toBe('string');
expect(challenge.length).toBe(43); // SHA-256 = 32 bytes = 43 base64url chars
// Base64url encoding (no padding, proper characters)
expect(challenge).not.toContain('=');
expect(challenge).not.toContain('+');
expect(challenge).not.toContain('/');
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
// Consistency for same verifier
const challenge1 = challengeFromVerifier(verifier);
const challenge2 = challengeFromVerifier(verifier);
expect(challenge1).toBe(challenge2);
expect(challenge1).toBe(challenge);
// Different challenges for different verifiers
const verifier2 = 'test-verifier-2';
const challenge3 = challengeFromVerifier(verifier2);
expect(challenge3).not.toBe(challenge);
});
it('should handle edge cases for verifier length', () => {
// Empty verifier
const emptyChallenge = challengeFromVerifier('');
expect(emptyChallenge).toBeDefined();
expect(emptyChallenge.length).toBe(43);
// Very long verifier
const longVerifier = 'a'.repeat(1000);
const longChallenge = challengeFromVerifier(longVerifier);
expect(longChallenge).toBeDefined();
expect(longChallenge.length).toBe(43); // SHA-256 always produces 32 bytes
});
it('should produce RFC 7636 compliant challenge', () => {
// Test with a known verifier
const verifier = generateVerifier();
const challenge = challengeFromVerifier(verifier);
// Verify it's using SHA-256 correctly
const expectedHash = crypto
.createHash('sha256')
.update(verifier)
.digest();
const expectedChallenge = expectedHash
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
expect(challenge).toBe(expectedChallenge);
});
});
describe('PKCE flow integration', () => {
it('should generate valid verifier and challenge pair', () => {
const verifier = generateVerifier();
const challenge = challengeFromVerifier(verifier);
expect(verifier).toBeDefined();
expect(challenge).toBeDefined();
expect(verifier).not.toBe(challenge);
// Verifier should be longer than challenge
expect(verifier.length).toBeGreaterThan(challenge.length);
});
it('should generate multiple unique pairs', () => {
const pair1 = {
verifier: generateVerifier(),
challenge: '',
};
pair1.challenge = challengeFromVerifier(pair1.verifier);
const pair2 = {
verifier: generateVerifier(),
challenge: '',
};
pair2.challenge = challengeFromVerifier(pair2.verifier);
expect(pair1.verifier).not.toBe(pair2.verifier);
expect(pair1.challenge).not.toBe(pair2.challenge);
});
it('should maintain one-to-one mapping between verifier and challenge', () => {
const verifier = generateVerifier();
const challenge1 = challengeFromVerifier(verifier);
const challenge2 = challengeFromVerifier(verifier);
const challenge3 = challengeFromVerifier(verifier);
// All challenges from same verifier should be identical
expect(challenge1).toBe(challenge2);
expect(challenge2).toBe(challenge3);
});
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
* 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 crypto from 'node:crypto';
function base64url(input: Buffer): string {
return input
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
export function generateVerifier(length = 64): string {
// length in bytes ~ 48 results in 64 base64url chars; keep within 43..128 chars
const bytes = crypto.randomBytes(Math.max(32, Math.min(96, length)));
return base64url(bytes);
}
export function challengeFromVerifier(verifier: string): string {
const hash = crypto.createHash('sha256').update(verifier).digest();
return base64url(hash);
}
@@ -0,0 +1,191 @@
/*
* 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 inquirer from 'inquirer';
import { pickInstance } from './prompt';
import * as storage from './storage';
jest.mock('inquirer');
jest.mock('./storage');
const mockStorage = storage as jest.Mocked<typeof storage>;
const mockInquirer = inquirer as jest.Mocked<typeof inquirer>;
describe('prompt', () => {
describe('pickInstance', () => {
const mockInstances = [
{
name: 'production',
baseUrl: 'https://backstage.example.com',
clientId: 'prod-client',
issuedAt: Date.now(),
accessToken: 'prod-token',
accessTokenExpiresAt: Date.now() + 3600_000,
selected: true,
},
{
name: 'staging',
baseUrl: 'https://staging.backstage.example.com',
clientId: 'staging-client',
issuedAt: Date.now(),
accessToken: 'staging-token',
accessTokenExpiresAt: Date.now() + 3600_000,
selected: false,
},
{
name: 'local',
baseUrl: 'http://localhost:7007',
clientId: 'local-client',
issuedAt: Date.now(),
accessToken: 'local-token',
accessTokenExpiresAt: Date.now() + 3600_000,
selected: false,
},
];
beforeEach(() => {
jest.clearAllMocks();
});
it('should return instance by name if provided', async () => {
mockStorage.getInstanceByName.mockResolvedValue(mockInstances[1]);
const result = await pickInstance('staging');
expect(result).toEqual(mockInstances[1]);
expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('staging');
expect(mockInquirer.prompt).not.toHaveBeenCalled();
});
it('should prompt for instance and show selected instance with asterisk prefix', async () => {
// Test with production selected
mockStorage.getAllInstances.mockResolvedValue({
instances: mockInstances,
selected: mockInstances[0],
});
mockInquirer.prompt.mockResolvedValue({ choice: 'staging' });
const result = await pickInstance();
expect(mockInquirer.prompt).toHaveBeenCalledWith([
{
type: 'list',
name: 'choice',
message: 'Select instance:',
choices: [
{
name: '* production (https://backstage.example.com)',
value: 'production',
},
{
name: ' staging (https://staging.backstage.example.com)',
value: 'staging',
},
{
name: ' local (http://localhost:7007)',
value: 'local',
},
],
default: 'production',
},
]);
expect(result).toEqual(mockInstances[1]);
// Test with staging selected
mockStorage.getAllInstances.mockResolvedValue({
instances: mockInstances,
selected: mockInstances[1],
});
mockInquirer.prompt.mockResolvedValue({ choice: 'staging' });
await pickInstance();
expect(mockInquirer.prompt).toHaveBeenCalledWith([
expect.objectContaining({
choices: [
{
name: ' production (https://backstage.example.com)',
value: 'production',
},
{
name: '* staging (https://staging.backstage.example.com)',
value: 'staging',
},
{
name: ' local (http://localhost:7007)',
value: 'local',
},
],
default: 'staging',
}),
]);
});
it('should throw error if no instances are available', async () => {
mockStorage.getAllInstances.mockResolvedValue({
instances: [],
selected: undefined,
});
await expect(pickInstance()).rejects.toThrow(
'No instances found. Run "auth login" to authenticate first.',
);
});
it('should throw error if selected instance is not found', async () => {
mockStorage.getAllInstances.mockResolvedValue({
instances: mockInstances,
selected: mockInstances[0],
});
mockInquirer.prompt.mockResolvedValue({ choice: 'non-existent' });
await expect(pickInstance()).rejects.toThrow(
"Instance 'non-existent' not found",
);
});
it('should handle single instance and use selected instance as default', async () => {
// Test single instance
const singleInstance = [mockInstances[0]];
mockStorage.getAllInstances.mockResolvedValue({
instances: singleInstance,
selected: mockInstances[0],
});
mockInquirer.prompt.mockResolvedValue({ choice: 'production' });
const result = await pickInstance();
expect(result).toEqual(mockInstances[0]);
expect(mockInquirer.prompt).toHaveBeenCalled();
// Test default selection matches selected instance
const selectedInstance = mockInstances[2];
mockStorage.getAllInstances.mockResolvedValue({
instances: mockInstances,
selected: selectedInstance,
});
mockInquirer.prompt.mockResolvedValue({ choice: 'local' });
await pickInstance();
expect(mockInquirer.prompt).toHaveBeenCalledWith([
expect.objectContaining({
default: 'local',
}),
]);
});
});
});
@@ -0,0 +1,58 @@
/*
* 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 inquirer from 'inquirer';
import { getInstanceByName, getAllInstances, StoredInstance } from './storage';
export async function pickInstance(name?: string): Promise<StoredInstance> {
if (name) {
return getInstanceByName(name);
}
const { instances, selected } = await getAllInstances();
if (instances.length === 0) {
throw new Error(
'No instances found. Run "auth login" to authenticate first.',
);
}
return await promptForInstance(instances, selected);
}
async function promptForInstance(
instances: StoredInstance[],
selected: StoredInstance | undefined,
): Promise<StoredInstance> {
const choices = instances.map(i => ({
name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`,
value: i.name,
}));
const { choice } = await inquirer.prompt<{ choice: string }>([
{
type: 'list',
name: 'choice',
message: 'Select instance:',
choices,
default: selected?.name,
},
]);
const instance = instances.find(i => i.name === choice);
if (!instance) {
throw new Error(`Instance '${choice}' not found`);
}
return instance;
}
@@ -0,0 +1,250 @@
/*
* 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.
*/
jest.mock('keytar', () => {
throw new Error('keytar not available');
});
import fs from 'fs-extra';
import path from 'node:path';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { getSecretStore, resetSecretStore } from './secretStore';
const mockDir = createMockDirectory();
describe('secretStore', () => {
beforeEach(() => {
mockDir.clear();
process.env.XDG_DATA_HOME = mockDir.resolve('data');
resetSecretStore();
});
afterEach(() => {
delete process.env.XDG_DATA_HOME;
resetSecretStore();
});
describe('FileSecretStore', () => {
it('should store and retrieve secrets', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
const result = await store.get('test-service', 'test-account');
expect(result).toBe('test-secret');
});
it('should return undefined for non-existent secrets', async () => {
const store = await getSecretStore();
const result = await store.get('test-service', 'test-account');
expect(result).toBeUndefined();
});
it('should delete secrets', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
let result = await store.get('test-service', 'test-account');
expect(result).toBe('test-secret');
await store.delete('test-service', 'test-account');
result = await store.get('test-service', 'test-account');
expect(result).toBeUndefined();
});
it('should not throw when deleting non-existent secrets', async () => {
const store = await getSecretStore();
await expect(
store.delete('non-existent-service', 'non-existent-account'),
).resolves.not.toThrow();
});
it('should create files with correct directory structure', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
const expectedDir = path.join(
mockDir.resolve('data'),
'backstage-cli',
'auth-secrets',
encodeURIComponent('test-service'),
);
const expectedFile = path.join(
expectedDir,
`${encodeURIComponent('test-account')}.secret`,
);
expect(await fs.pathExists(expectedFile)).toBe(true);
expect(await fs.pathExists(expectedDir)).toBe(true);
});
it('should create files with correct permissions (0o600)', async () => {
// File permissions are not reliably enforced on Windows
if (process.platform === 'win32') {
return;
}
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
const expectedFile = path.join(
mockDir.resolve('data'),
'backstage-cli',
'auth-secrets',
encodeURIComponent('test-service'),
`${encodeURIComponent('test-account')}.secret`,
);
const stats = await fs.stat(expectedFile);
const mode = stats.mode & 0o777;
expect(mode).toBe(0o600);
});
it('should encode service and account names in file path', async () => {
const store = await getSecretStore();
await store.set('my-service/test', 'my-account@test', 'test-secret');
const result = await store.get('my-service/test', 'my-account@test');
expect(result).toBe('test-secret');
const expectedFile = path.join(
mockDir.resolve('data'),
'backstage-cli',
'auth-secrets',
encodeURIComponent('my-service/test'),
`${encodeURIComponent('my-account@test')}.secret`,
);
expect(await fs.pathExists(expectedFile)).toBe(true);
});
it('should handle unicode characters in service and account names', async () => {
const store = await getSecretStore();
await store.set('service-测试', 'account-🚀', 'test-secret');
const result = await store.get('service-测试', 'account-🚀');
expect(result).toBe('test-secret');
});
it('should handle multiple secrets for same service', async () => {
const store = await getSecretStore();
await store.set('test-service', 'account1', 'secret1');
await store.set('test-service', 'account2', 'secret2');
const result1 = await store.get('test-service', 'account1');
const result2 = await store.get('test-service', 'account2');
expect(result1).toBe('secret1');
expect(result2).toBe('secret2');
});
it('should handle multiple services', async () => {
const store = await getSecretStore();
await store.set('service1', 'account', 'secret1');
await store.set('service2', 'account', 'secret2');
const result1 = await store.get('service1', 'account');
const result2 = await store.get('service2', 'account');
expect(result1).toBe('secret1');
expect(result2).toBe('secret2');
});
it('should update existing secrets', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'old-secret');
await store.set('test-service', 'test-account', 'new-secret');
const result = await store.get('test-service', 'test-account');
expect(result).toBe('new-secret');
});
it('should handle empty string secrets', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', '');
const result = await store.get('test-service', 'test-account');
expect(result).toBe('');
});
it('should handle very long secrets', async () => {
const store = await getSecretStore();
const longSecret = 'a'.repeat(10000);
await store.set('test-service', 'test-account', longSecret);
const result = await store.get('test-service', 'test-account');
expect(result).toBe(longSecret);
});
it('should use XDG_DATA_HOME when set', async () => {
const customDataHome = mockDir.resolve('custom-data');
process.env.XDG_DATA_HOME = customDataHome;
resetSecretStore();
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
const expectedFile = path.join(
customDataHome,
'backstage-cli',
'auth-secrets',
encodeURIComponent('test-service'),
`${encodeURIComponent('test-account')}.secret`,
);
expect(await fs.pathExists(expectedFile)).toBe(true);
const result = await store.get('test-service', 'test-account');
expect(result).toBe('test-secret');
});
});
describe('getSecretStore singleton', () => {
it('should return the same instance on multiple calls', async () => {
const store1 = await getSecretStore();
const store2 = await getSecretStore();
expect(store1).toBe(store2);
});
it('should create new instance after reset', async () => {
const store1 = await getSecretStore();
resetSecretStore();
const store2 = await getSecretStore();
expect(store1).not.toBe(store2);
});
});
describe('fallback behavior', () => {
it('should fall back to FileSecretStore when keytar is not available', async () => {
const store = await getSecretStore();
await store.set('test-service', 'test-account', 'test-secret');
const result = await store.get('test-service', 'test-account');
expect(result).toBe('test-secret');
const expectedFile = path.join(
mockDir.resolve('data'),
'backstage-cli',
'auth-secrets',
encodeURIComponent('test-service'),
`${encodeURIComponent('test-account')}.secret`,
);
expect(await fs.pathExists(expectedFile)).toBe(true);
});
});
});
@@ -0,0 +1,110 @@
/*
* 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';
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;
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,420 @@
/*
* 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 path from 'node:path';
import { NotFoundError } from '@backstage/errors';
import { createMockDirectory } from '@backstage/backend-test-utils';
import {
getAllInstances,
getSelectedInstance,
getInstanceByName,
upsertInstance,
removeInstance,
setSelectedInstance,
withMetadataLock,
StoredInstance,
} from './storage';
const mockDir = createMockDirectory();
describe('storage', () => {
const mockInstance1: StoredInstance = {
name: 'production',
baseUrl: 'https://backstage.example.com',
clientId: 'prod-client',
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + 3600_000,
selected: true,
};
const mockInstance2: StoredInstance = {
name: 'staging',
baseUrl: 'https://staging.backstage.example.com',
clientId: 'staging-client',
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + 3600_000,
selected: false,
};
beforeEach(() => {
mockDir.clear();
process.env.XDG_CONFIG_HOME = mockDir.resolve('config');
});
afterEach(() => {
delete process.env.XDG_CONFIG_HOME;
});
describe('getAllInstances', () => {
it('should return empty array if file does not exist or is empty', async () => {
const result1 = await getAllInstances();
expect(result1).toEqual({ instances: [], selected: undefined });
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': '',
});
const result2 = await getAllInstances();
expect(result2).toEqual({ instances: [], selected: undefined });
});
it('should parse and return instances from YAML', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
selected: true
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
`,
});
const result = await getAllInstances();
expect(result.instances).toHaveLength(2);
expect(result.selected?.name).toBe('production');
});
it('should select first instance if none marked as selected', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
`,
});
const result = await getAllInstances();
expect(result.selected?.name).toBe('production');
});
it('should return empty array if YAML parsing fails', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': 'invalid: yaml: [',
});
const result = await getAllInstances();
expect(result).toEqual({ instances: [], selected: undefined });
});
it('should normalize selected property across instances', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
selected: true
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
selected: true
`,
});
const result = await getAllInstances();
const selectedCount = result.instances.filter(i => i.selected).length;
expect(selectedCount).toBe(1);
});
});
describe('getSelectedInstance', () => {
it('should return instance by name if provided', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
`,
});
const result = await getSelectedInstance('production');
expect(result.name).toBe('production');
});
it('should return selected instance if no name provided', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
selected: true
`,
});
const result = await getSelectedInstance();
expect(result.name).toBe('staging');
});
it('should throw error if no instances exist', async () => {
await expect(getSelectedInstance()).rejects.toThrow(
'No instances found. Run "auth login" to authenticate first.',
);
});
});
describe('getInstanceByName', () => {
it('should return instance with matching name', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
`,
});
const result = await getInstanceByName('production');
expect(result.name).toBe('production');
});
it('should throw NotFoundError if instance does not exist', async () => {
await expect(getInstanceByName('nonexistent')).rejects.toThrow(
NotFoundError,
);
await expect(getInstanceByName('nonexistent')).rejects.toThrow(
"Instance 'nonexistent' not found",
);
});
});
describe('upsertInstance', () => {
it('should add new instance if it does not exist', async () => {
await upsertInstance(mockInstance1);
const result = await getAllInstances();
expect(result.instances).toHaveLength(1);
expect(result.instances[0].name).toBe('production');
});
it('should update existing instance', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
`,
});
const updatedInstance = {
...mockInstance1,
clientId: 'updated-client',
};
await upsertInstance(updatedInstance);
const result = await getInstanceByName('production');
expect(result.clientId).toBe('updated-client');
});
});
describe('removeInstance', () => {
it('should remove instance with matching name', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
`,
});
await removeInstance('production');
const result = await getAllInstances();
expect(result.instances).toHaveLength(1);
expect(result.instances[0].name).toBe('staging');
});
it('should do nothing if instance does not exist', async () => {
await removeInstance('nonexistent');
const result = await getAllInstances();
expect(result.instances).toHaveLength(0);
});
});
describe('setSelectedInstance', () => {
it('should set selected instance and unselect others', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
baseUrl: https://backstage.example.com
clientId: prod-client
issuedAt: ${mockInstance1.issuedAt}
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
selected: true
- name: staging
baseUrl: https://staging.backstage.example.com
clientId: staging-client
issuedAt: ${mockInstance2.issuedAt}
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
`,
});
await setSelectedInstance('staging');
const result = await getAllInstances();
expect(result.selected?.name).toBe('staging');
const prodInstance = result.instances.find(i => i.name === 'production');
expect(prodInstance?.selected).toBe(false);
});
it('should throw error if instance does not exist', async () => {
await expect(setSelectedInstance('nonexistent')).rejects.toThrow(
"Unknown instance 'nonexistent'",
);
});
});
describe('withMetadataLock', () => {
it('should acquire and release lock', async () => {
const callback = jest.fn().mockResolvedValue('result');
const result = await withMetadataLock(callback);
expect(callback).toHaveBeenCalled();
expect(result).toBe('result');
});
it('should release lock even if callback throws', async () => {
const error = new Error('Test error');
const callback = jest.fn().mockRejectedValue(error);
await expect(withMetadataLock(callback)).rejects.toThrow(error);
// Lock should still be released, allowing subsequent calls
const callback2 = jest.fn().mockResolvedValue('result');
await expect(withMetadataLock(callback2)).resolves.toBe('result');
});
});
describe('file path resolution', () => {
it('should use XDG_CONFIG_HOME when set', async () => {
const customConfigHome = mockDir.resolve('custom-config');
process.env.XDG_CONFIG_HOME = customConfigHome;
await upsertInstance(mockInstance1);
const result = await getAllInstances();
expect(result.instances).toHaveLength(1);
expect(result.instances[0].name).toBe('production');
// Verify file was created in custom location
const expectedFile = path.join(
customConfigHome,
'backstage-cli',
'auth-instances.yaml',
);
expect(await fs.pathExists(expectedFile)).toBe(true);
});
it('should create files with correct permissions (0o600)', async () => {
// File permissions are not reliably enforced on Windows
if (process.platform === 'win32') {
return;
}
await upsertInstance(mockInstance1);
const file = path.join(
mockDir.resolve('config'),
'backstage-cli',
'auth-instances.yaml',
);
const stats = await fs.stat(file);
const mode = stats.mode & 0o777;
expect(mode).toBe(0o600);
});
it('should handle invalid schema and missing fields gracefully', async () => {
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: ""
baseUrl: not-a-url
clientId: ""
`,
});
const result1 = await getAllInstances();
expect(result1.instances).toHaveLength(0);
mockDir.setContent({
'config/backstage-cli/auth-instances.yaml': `instances:
- name: production
`,
});
const result2 = await getAllInstances();
expect(result2.instances).toHaveLength(0);
});
});
});
@@ -0,0 +1,177 @@
/*
* 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 lockfile from 'proper-lockfile';
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(),
});
export type StoredInstance = z.infer<typeof storedInstanceSchema>;
const authYamlSchema = z.object({
instances: z.array(storedInstanceSchema).default([]),
});
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);
}
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: [] };
}
}
async function writeAll(data: { instances: StoredInstance[] }): Promise<void> {
const file = getMetadataFilePath();
await fs.ensureDir(path.dirname(file));
const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false });
await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 });
}
export async function getAllInstances(): Promise<{
instances: StoredInstance[];
selected: StoredInstance | undefined;
}> {
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,
})),
selected,
};
}
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;
}
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;
}
export async function upsertInstance(instance: StoredInstance): Promise<void> {
const data = await readAll();
const idx = data.instances.findIndex(i => i.name === instance.name);
if (idx === -1) {
data.instances.push(instance);
} else {
data.instances[idx] = instance;
}
await writeAll(data);
}
export async function removeInstance(name: string): Promise<void> {
const data = await readAll();
const next = data.instances.filter(i => i.name !== name);
if (next.length !== data.instances.length) {
await writeAll({ instances: next });
}
}
export async function setSelectedInstance(name: string): Promise<void> {
return withMetadataLock(async () => {
const data = await readAll();
let found = false;
data.instances = data.instances.map(i => {
if (i.name === name) {
found = true;
return { ...i, selected: true };
}
const { selected, ...rest } = i;
return { ...rest, selected: false };
});
if (!found) {
throw new Error(`Unknown instance '${name}'`);
}
await writeAll(data);
});
}
export async function withMetadataLock<T>(fn: () => Promise<T>): Promise<T> {
const file = getMetadataFilePath();
await fs.ensureDir(path.dirname(file));
if (!(await fs.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();
}
}