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:
@@ -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`
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user