Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,17 @@ async function persistSiteCredential(

if (available) {
const credentialRef = credentialRefForAlias(alias);
await store.set(credentialRef, staffTokenInput);
return { credentialRef };
try {
await store.set(credentialRef, staffTokenInput);
return { credentialRef };
} catch (err) {
if (!allowInsecureStorage) {
throw err;
}
// store.set() failed even though isAvailable() returned true (e.g. D-Bus
// service became unavailable between probe and write). Fall through to
// plaintext storage since --insecure-storage was explicitly requested.
}
}

if (!allowInsecureStorage) {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,11 @@ function createLinuxAdapter(): CredentialStoreAdapter {
LINUX_ATTR_REF,
'__probe__',
]);
return probe.code === 0 || probe.code === 1;
// code 1 = credential not found (service healthy); code 0 = found.
// When the Secret Service is not activatable, secret-tool exits with a
// non-zero code AND writes a D-Bus error to stderr. A healthy "not found"
// response produces no stderr output, so any stderr indicates unavailability.
return (probe.code === 0 || probe.code === 1) && probe.stderr.trim() === '';
} catch {
return false;
}
Expand Down
48 changes: 48 additions & 0 deletions tests/credentials-and-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { readUserConfig } from '../src/lib/config.js';
import { setCredentialStoreForTests } from '../src/lib/credentials.js';
import { ExitCode } from '../src/lib/errors.js';
import {
createBrokenCredentialStore,
createMemoryCredentialStore,
createUnavailableCredentialStore,
} from './helpers/mock-credentials.js';
Expand Down Expand Up @@ -136,6 +137,53 @@ describe('credential storage and security defaults', () => {
expect(raw.sites?.myblog?.credentialRef).toBeUndefined();
});

test('falls back to plaintext when store reports available but set throws and --insecure-storage is passed', async () => {
setCredentialStoreForTests(createBrokenCredentialStore());

await expect(
run([
'node',
'ghst',
'auth',
'login',
'--non-interactive',
'--url',
'https://myblog.ghost.io',
'--staff-token',
KEY,
'--site',
'myblog',
'--insecure-storage',
]),
).resolves.toBe(ExitCode.SUCCESS);

const raw = JSON.parse(await fs.readFile(path.join(configDir, 'config.json'), 'utf8')) as {
sites?: Record<string, { staffAccessToken?: string; credentialRef?: string }>;
};
expect(raw.sites?.myblog?.staffAccessToken).toBe(KEY);
expect(raw.sites?.myblog?.credentialRef).toBeUndefined();
});

test('re-throws store set error without --insecure-storage when store reports available but set throws', async () => {
setCredentialStoreForTests(createBrokenCredentialStore());

await expect(
run([
'node',
'ghst',
'auth',
'login',
'--non-interactive',
'--url',
'https://myblog.ghost.io',
'--staff-token',
KEY,
'--site',
'myblog',
]),
).resolves.toBe(ExitCode.GENERAL_ERROR);
});

test('migrates plaintext staff tokens into secure store on read', async () => {
const store = createMemoryCredentialStore();
setCredentialStoreForTests(store);
Expand Down
11 changes: 11 additions & 0 deletions tests/helpers/mock-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ export function createUnavailableCredentialStore(): CredentialStore {
delete: async () => undefined,
};
}

export function createBrokenCredentialStore(): CredentialStore {
return {
isAvailable: async () => true,
set: async () => {
throw new Error('Failed to store credential in Secret Service: secret-tool: The name is not activatable');
},
get: async () => null,
delete: async () => undefined,
};
}
25 changes: 25 additions & 0 deletions tests/lib-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,31 @@ describe.sequential('credential store adapters', () => {
await expect(store.isAvailable()).resolves.toBe(false);
});

test('linux adapter returns unavailable when probe exits code 1 with D-Bus stderr', async () => {
delete process.env.VITEST;
setPlatform('linux');
resetCredentialStoreCacheForTests();

queueSpawnOutcomes([
{ code: 1, stderr: 'secret-tool: The name is not activatable\n' },
]);

const store = getCredentialStore();
await expect(store.isAvailable()).resolves.toBe(false);
});

test('linux adapter considers service available only when probe exits cleanly with no stderr', async () => {
delete process.env.VITEST;
setPlatform('linux');
resetCredentialStoreCacheForTests();

// code 1 with no stderr = "not found" (service healthy)
queueSpawnOutcomes([{ code: 1, stderr: '' }]);

const store = getCredentialStore();
await expect(store.isAvailable()).resolves.toBe(true);
});

test('linux adapter handles input piping and error branches', async () => {
delete process.env.VITEST;
setPlatform('linux');
Expand Down