Skip to content
Merged
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
7 changes: 7 additions & 0 deletions messages/accesstoken.store.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Authorize an org using an existing Salesforce access token.

By default, the command runs interactively and asks you for the access token. If you previously authorized the org, the command prompts whether you want to overwrite the local file. Specify --no-prompt to not be prompted.

If you pipe the access token to the command (so stdin isn't an interactive terminal), the command reads the token from stdin and doesn't prompt to overwrite an existing file.

To use the command in a CI/CD script, set the SF_ACCESS_TOKEN environment variable to the access token. Then run the command with the --no-prompt parameter.

# examples
Expand All @@ -27,3 +29,8 @@ It should follow this pattern: %s.

A file already exists for user "%s", which is associated with the access token you provided.
Are you sure you want to overwrite the existing file?

# stdinTimeout

Timed out while reading the access token from stdin.
Pipe the access token to the command, or set the SF_ACCESS_TOKEN environment variable and use --no-prompt.
66 changes: 60 additions & 6 deletions src/commands/org/login/access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ export default class LoginAccessToken extends SfCommand<AuthFields> {
}

private async overwriteAuthInfo(username: string): Promise<boolean> {
if (!this.flags['no-prompt']) {
// Only prompt to overwrite when we can actually receive an answer. When stdin is not a
// TTY (e.g. the token was piped in) the interactive prompt has no input to read and would
// reject with "User force closed the prompt with 13 null", so treat it like --no-prompt.
if (!this.flags['no-prompt'] && process.stdin.isTTY) {
const stateAggregator = await StateAggregator.getInstance();
if (await stateAggregator.orgs.exists(username)) {
return this.confirm({ message: messages.getMessage('overwriteAccessTokenAuthUserFile', [username]) });
Expand All @@ -143,14 +146,65 @@ export default class LoginAccessToken extends SfCommand<AuthFields> {

private async getAccessToken(): Promise<string> {
const accessToken =
env.getString('SF_ACCESS_TOKEN') ??
env.getString('SFDX_ACCESS_TOKEN') ??
(this.flags['no-prompt'] === true
? '' // will throw when validating
: await this.secretPrompt({ message: commonMessages.getMessage('accessTokenStdin') }));
env.getString('SF_ACCESS_TOKEN') ?? env.getString('SFDX_ACCESS_TOKEN') ?? (await this.resolveAccessToken());
if (!matchesAccessToken(accessToken)) {
throw new SfError(messages.getMessage('invalidAccessTokenFormat', [ACCESS_TOKEN_FORMAT]));
}
return accessToken;
}

private async resolveAccessToken(): Promise<string> {
if (this.flags['no-prompt']) {
// will throw when validating
return '';
}
// When stdin is piped (not a TTY) the interactive secret prompt can't be used reliably,
// so read the token directly from the stream. This is the CI/CD "pipe the token" workflow.
if (!process.stdin.isTTY) {
return readPipedStdin();
}
return this.secretPrompt({ message: commonMessages.getMessage('accessTokenStdin') });
}
}

/**
* Read all data piped to stdin and return it trimmed. Returns '' if nothing was piped.
*
* A timeout guards against a stdin stream that is opened but never sends data or EOF (e.g. a
* pipe inherited from a supervisor process, or a terminal that reports a non-TTY stdin such as
* Git Bash/mintty on Windows), which would otherwise hang the command forever. On timeout the
* stream listeners are removed so no read is left dangling.
*/
async function readPipedStdin(ms = 60_000): Promise<string> {
const stdin = process.stdin;
const chunks: string[] = [];

return new Promise<string>((resolve, reject) => {
const onData = (chunk: Buffer | string): void => {
chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
};
const cleanup = (): void => {
clearTimeout(timer);
stdin.removeListener('data', onData);
stdin.removeListener('end', onEnd);
stdin.removeListener('error', onError);
};
const onEnd = (): void => {
cleanup();
resolve(chunks.join('').trim());
};
const onError = (err: Error): void => {
cleanup();
reject(err);
};
const timer = setTimeout(() => {
cleanup();
reject(new SfError(messages.getMessage('stdinTimeout')));
}, ms);
timer.unref();

stdin.on('data', onData);
stdin.once('end', onEnd);
stdin.once('error', onError);
});
}
139 changes: 139 additions & 0 deletions test/commands/org/login/access-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { Readable } from 'node:stream';
import { AuthFields, AuthInfo, StateAggregator } from '@salesforce/core';
import { assert, expect } from 'chai';
import { TestContext } from '@salesforce/core/testSetup';
Expand Down Expand Up @@ -49,8 +50,57 @@ describe('org:login:access-token', () => {
/* eslint-enable camelcase */
let stubSfCommandUxStubs: ReturnType<typeof stubSfCommandUx>;
let prompterStubs: ReturnType<typeof stubPrompter>;
const originalStdin = process.stdin;

const setStdinIsTTY = (isTTY: boolean): void => {
Object.defineProperty(process.stdin, 'isTTY', { value: isTTY, configurable: true });
};

/** Ensure the token env vars don't short-circuit the stdin-reading path. */
const stubNoTokenEnv = (): void => {
$$.SANDBOX.stub(env, 'getString')
.withArgs('SF_ACCESS_TOKEN')
// @ts-expect-error getString can return undefined
.returns(undefined)
.withArgs('SFDX_ACCESS_TOKEN')
// @ts-expect-error getString can return undefined
.returns(undefined);
};

/** Replace process.stdin with a non-TTY readable and clear any token env vars. */
const useStdin = (readable: Readable): void => {
stubNoTokenEnv();
Object.defineProperty(readable, 'isTTY', { value: undefined, configurable: true });
Object.defineProperty(process, 'stdin', { value: readable, configurable: true });
};

/** Simulate a non-TTY stdin that emits `content` then EOF. */
const pipeToStdin = (content: string): void => {
useStdin(Readable.from([content]));
};

/** A non-TTY stdin that is open but never sends data or EOF (would hang without a timeout). */
const openStdin = (): Readable => {
const stdin = new Readable({
read(): void {
/* no-op: never emits data or end */
},
});
useStdin(stdin);
return stdin;
};

/** Wait until the command under test has attached its stdin 'data' listener. */
const waitForStdinRead = async (stdin: Readable): Promise<void> => {
while (stdin.listenerCount('data') === 0) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setImmediate(resolve));
}
};

beforeEach(() => {
// default to an interactive terminal; individual tests override as needed
setStdinIsTTY(true);
// @ts-expect-error because private method
$$.SANDBOX.stub(Store.prototype, 'saveAuthInfo').resolves(userInfo);
$$.SANDBOX.stub(AuthInfo.prototype, 'getUsername').returns(authFields.username);
Expand All @@ -67,6 +117,10 @@ describe('org:login:access-token', () => {
prompterStubs = stubPrompter($$.SANDBOX);
});

afterEach(() => {
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
});

it('should return auth fields after successful auth', async () => {
prompterStubs.secret.resolves(accessToken);

Expand Down Expand Up @@ -104,6 +158,91 @@ describe('org:login:access-token', () => {
expect(prompterStubs.confirm.callCount).to.equal(1);
});

it('should read the token piped to stdin and not prompt when auth file exists (non-TTY)', async () => {
// Core regression for W-22954140 / GH #3573: token piped via stdin + existing auth file.
// Drives the real stdin-reading path (no env var short-circuit).
pipeToStdin(`${accessToken}\n`);
$$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({
// @ts-expect-error because incomplete interface
orgs: {
exists: () => Promise.resolve(true),
},
});

const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']);
expect(result).to.deep.equal(redactedAuthFields);
// token came from stdin, not the interactive prompt
expect(prompterStubs.secret.callCount).to.equal(0);
// overwrite prompt skipped because stdin isn't a TTY
expect(prompterStubs.confirm.callCount).to.equal(0);
expect(stubSfCommandUxStubs.logSuccess.callCount).to.equal(1);
});

it('should trim surrounding whitespace/newlines from a piped token', async () => {
pipeToStdin(` ${accessToken}\n\n`);
$$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({
// @ts-expect-error because incomplete interface
orgs: { exists: () => Promise.resolve(false) },
});

const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']);
expect(result).to.deep.equal(redactedAuthFields);
expect(prompterStubs.secret.callCount).to.equal(0);
});

it('should throw invalid-format error when stdin is empty (non-TTY)', async () => {
pipeToStdin('');
try {
await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']);
assert(false, 'should throw error');
} catch (e) {
assert(e instanceof Error);
expect(e.message).to.include("The access token isn't in the correct format");
}
// never falls back to an interactive prompt
expect(prompterStubs.secret.callCount).to.equal(0);
});

it('should time out (not hang) when non-TTY stdin never sends data or EOF', async () => {
const clock = $$.SANDBOX.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const stdin = openStdin();

const runPromise = Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']);
// Wait until the command has reached readPipedStdin and registered its listeners/timer,
// then advance the fake clock past the timeout. Avoids ticking before the timer exists.
await waitForStdinRead(stdin);
await clock.tickAsync(60_000);

try {
await runPromise;
assert(false, 'should throw error');
} catch (e) {
assert(e instanceof Error);
expect(e.message).to.include('Timed out while reading the access token from stdin');
}
expect(prompterStubs.secret.callCount).to.equal(0);
});

it('should reject and clean up listeners when non-TTY stdin errors', async () => {
const stdin = openStdin();

const runPromise = Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']);
await waitForStdinRead(stdin);
stdin.emit('error', new Error('stdin boom'));

try {
await runPromise;
assert(false, 'should throw error');
} catch (e) {
assert(e instanceof Error);
expect(e.message).to.include('stdin boom');
}
// listeners removed on cleanup so nothing dangles
expect(stdin.listenerCount('data')).to.equal(0);
expect(stdin.listenerCount('end')).to.equal(0);
expect(stdin.listenerCount('error')).to.equal(0);
});

it('should show that auth file does not already exist', async () => {
prompterStubs.secret.resolves(accessToken);
$$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({
Expand Down
Loading