Skip to content

Commit

Permalink
Issue 58: credentials expiry warning and workaround
Browse files Browse the repository at this point in the history
  • Loading branch information
plumdog committed Aug 15, 2023
1 parent 0012a05 commit 7c6f6a2
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 32 deletions.
85 changes: 55 additions & 30 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { hasKey } from './utils';

const execPromise = util.promisify(exec);

const jsSdkExpiryWindowSeconds = 15 * 60;

interface RunProps {
verbose: boolean;
profile: string | undefined;
Expand All @@ -23,15 +25,23 @@ interface RunProps {

type LogFn = (msg: string) => void;

const getLogger = (verbose: boolean): LogFn => {
return (msg: string): void => {
type Logger = {
info: LogFn;
warn: LogFn;
};

const getLogger = (verbose: boolean): Logger => ({
info: (msg: string): void => {
if (verbose) {
console.error('INFO:', msg);
}
};
};
},
warn: (msg: string): void => {
console.error('WARN:', msg);
},
});

let log: LogFn;
let log: Logger;

interface GetRoleContext {
ssoConfig: SSOConfigOptions;
Expand Down Expand Up @@ -74,7 +84,7 @@ interface SSOLoginContext {
}

const runSsoLogin = async (context: SSOLoginContext): Promise<void> => {
log('No valid SSO cache file found, running login...');
log.info('No valid SSO cache file found, running login...');
await execPromise('aws sso login', {
env: {
...process.env,
Expand All @@ -83,42 +93,42 @@ const runSsoLogin = async (context: SSOLoginContext): Promise<void> => {
});
context.haveRunSsoLogin = true;

log('Login completed, trying again to retrieve credentials from SSO cache');
log.info('Login completed, trying again to retrieve credentials from SSO cache');
context.latestCacheFile = await findLatestCacheFile();
};

export const run = async (props: RunProps): Promise<void> => {
log = getLogger(props.verbose);

log('Starting');
log.info('Starting');

log(`Application version: ${getVersionNumber()}`);
log.info(`Application version: ${getVersionNumber()}`);

log('Checking CLI version...');
log.info('Checking CLI version...');

if (!(await checkCLIVersion())) {
throw new BadAWSCLIVersionError('Need CLI version 2, see https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html, run `aws --version` to inspect version');
}

log('CLI version OK');
log.info('CLI version OK');

if (props.force) {
log('Deleting credentials, and SSO and CLI caches');
log.info('Deleting credentials, and SSO and CLI caches');
await deleteCredentialsAndCaches();
}

if (props.credentialsProcessOutput) {
log('Attempting to retrieve credentials from role credentials cache file');
log.info('Attempting to retrieve credentials from role credentials cache file');
const creds = await readCredentialsCacheFile();
if (typeof creds !== 'undefined') {
log('Found credentials in role credentials cache file, outputting');
log.info('Found credentials in role credentials cache file, outputting');
printCredentials(creds);
return;
}
log('No valid credentials found in role credentials cache file, continuing');
log.info('No valid credentials found in role credentials cache file, continuing');
}

log('Locating latest SSO cache file...');
log.info('Locating latest SSO cache file...');

const ssoLoginContext: SSOLoginContext = {
haveRunSsoLogin: false,
Expand All @@ -130,11 +140,11 @@ export const run = async (props: RunProps): Promise<void> => {
await runSsoLogin(ssoLoginContext);
}

log('Retrieving SSO configuration from AWS config file...');
log.info('Retrieving SSO configuration from AWS config file...');
const ssoConfig = await findSSOConfigFromAWSConfig(props.profile);
log('Got SSO configuration');
log.info('Got SSO configuration');

log('Using SSO credentials to get role credentials...');
log.info('Using SSO credentials to get role credentials...');

let roleCredentialsOutput: string;
const getRoleContext = {
Expand All @@ -148,35 +158,50 @@ export const run = async (props: RunProps): Promise<void> => {
if (ssoLoginContext.haveRunSsoLogin) {
throw err;
} else {
log('Expiry date appears to be deceptive, running login...');
log.info('Expiry date appears to be deceptive, running login...');
await runSsoLogin(ssoLoginContext);
log('Again using SSO credentials to get role credentials...');
log.info('Again using SSO credentials to get role credentials...');
roleCredentialsOutput = await runGetRoleCredentialsCmdOutput(getRoleContext);
}
} else {
throw err;
}
}

log('Parsing role credentials...');
log.info('Parsing role credentials...');
const roleCredentials = parseRoleCredentialsOutput(roleCredentialsOutput);
log('Got role credentials');
log.info('Got role credentials');

if (props.credentialsProcessOutput) {
log('Writing role credentials to cache in home directory...');
log.info('Writing role credentials to cache in home directory...');
await writeCredentialsCacheFile(roleCredentials);
log('Wrote role credentials');
log.info('Wrote role credentials');

log('Printing role credentials for credentials_process');
log.info('Printing role credentials for credentials_process');
printCredentials(roleCredentials);
log('Printed role credentials');
log.info('Printed role credentials');
} else {
log('Writing role credentials to AWS credentials file...');
log.info('Writing role credentials to AWS credentials file...');
await writeCredentialsFile(roleCredentials, props.profile);
log('Wrote role credentials');
log.info('Wrote role credentials');
}

/* istanbul ignore else */
if (ssoLoginContext.latestCacheFile) {
const ssoExpiryDate = ssoLoginContext.latestCacheFile.expiresAt;
if (ssoExpiryDate) {
const credentialsExpireInSeconds = (ssoExpiryDate.getTime() - new Date().getTime()) / 1000;
log.info(`Credentials from SSO expire in ${credentialsExpireInSeconds} seconds`);

if (credentialsExpireInSeconds < jsSdkExpiryWindowSeconds) {
log.warn(`Credentials from SSO expire in ${credentialsExpireInSeconds} seconds, which is within the AWS JS SDK's expiry window of ${jsSdkExpiryWindowSeconds} seconds.`);
log.warn('This may cause issues when using these credentials with the JS SDK, in particular the AWS CDK.');
log.warn(`Workaround: go to ${ssoConfig.startUrl ?? 'your AWS SSO login URL'}, click 'Sign out', then re-run this command with --force`);
}
}
}

log('Done, exiting cleanly');
log.info('Done, exiting cleanly');
};

export const main = async (args: Array<string>): Promise<void> => {
Expand Down
12 changes: 11 additions & 1 deletion src/ssoConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const fsPromises = fs.promises;
export interface SSOConfigOptions {
readonly roleName: string;
readonly accountId: string;
readonly startUrl?: string;
}

export const findSSOConfigFromAWSConfig = async (profile: string | undefined): Promise<SSOConfigOptions> => {
Expand Down Expand Up @@ -46,11 +47,20 @@ export const findSSOConfigFromAWSConfig = async (profile: string | undefined): P
}
const accountId = section['sso_account_id'];
if (typeof accountId !== 'string') {
throw new MissingSSOConfigError(`Bad type for sso_role_name from [${sectionName}] section in ${filepath}`);
throw new MissingSSOConfigError(`Bad type for sso_account_id from [${sectionName}] section in ${filepath}`);
}

let startUrl: string | undefined = undefined;
if (hasKey('sso_start_url', section)) {
const rawStartUrl = section['sso_start_url'];
if (typeof rawStartUrl === 'string') {
startUrl = rawStartUrl;
}
}

return {
roleName,
accountId,
startUrl,
};
};
83 changes: 82 additions & 1 deletion src/tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const emptyOutput: CmdOutput = {
stderr: '',
};

const minutesInTheFuture = (minutes: number): Date => {
return new Date(new Date().getTime() + minutes * 60 * 1000);
};

const mockExecCommandsFactory = (mockExecCommands: MockExecCommands): MockExec => {
return (cmd: string, options: unknown, callback: (err: Error | null, out: CmdOutput) => void): void => {
for (const key in mockExecCommands) {
Expand Down Expand Up @@ -55,7 +59,7 @@ const defaultExecMocks = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'aws sso login': (cmd: string, options: unknown): void => {
const content = {
expiresAt: new Date(new Date().getTime() + 60 * 1000),
expiresAt: minutesInTheFuture(20),
region: 'myregion',
accessToken: 'myaccesstoken',
};
Expand Down Expand Up @@ -168,6 +172,83 @@ describe('run', () => {
expect(foundCredentialsContent).toEqual(expectedLines.join('\n'));
});

test('run, credentials expiry within window', async () => {
const configLines = ['[default]', 'sso_role_name = myssorolename', 'sso_account_id = myssoaccountid', ''];
fs.writeFileSync(path.join(os.homedir(), '.aws/config'), configLines.join('\n'), 'utf8');

const execMock = (exec as unknown) as jest.Mock<void>;
execMock.mockImplementation(
mockExecCommandsFactory({
...defaultExecMocks,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'aws sso login': (cmd: string, options: unknown): void => {
const content = {
// Expires in 5 minutes
expiresAt: minutesInTheFuture(5),
region: 'myregion',
accessToken: 'myaccesstoken',
};
const cacheFilePath = path.join(os.homedir(), '.aws/sso/cache/example.json');
fs.mkdirSync(path.dirname(cacheFilePath), {
recursive: true,
});
fs.writeFileSync(cacheFilePath, JSON.stringify(content), 'utf8');
},
}),
);

const consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation();

await run({
verbose: false,
profile: undefined,
credentialsProcessOutput: false,
force: false,
skipExpiryCheck: false,
});

expect(consoleErrorSpy).toHaveBeenCalledWith('WARN:', 'This may cause issues when using these credentials with the JS SDK, in particular the AWS CDK.');
expect(consoleErrorSpy).toHaveBeenCalledWith('WARN:', `Workaround: go to your AWS SSO login URL, click 'Sign out', then re-run this command with --force`);
});

test('run, credentials expiry within window, useful error message', async () => {
const configLines = ['[default]', 'sso_role_name = myssorolename', 'sso_account_id = myssoaccountid', 'sso_start_url = mystarturl', ''];
fs.writeFileSync(path.join(os.homedir(), '.aws/config'), configLines.join('\n'), 'utf8');

const execMock = (exec as unknown) as jest.Mock<void>;
execMock.mockImplementation(
mockExecCommandsFactory({
...defaultExecMocks,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'aws sso login': (cmd: string, options: unknown): void => {
const content = {
// Expires in 5 minutes
expiresAt: minutesInTheFuture(5),
region: 'myregion',
accessToken: 'myaccesstoken',
};
const cacheFilePath = path.join(os.homedir(), '.aws/sso/cache/example.json');
fs.mkdirSync(path.dirname(cacheFilePath), {
recursive: true,
});
fs.writeFileSync(cacheFilePath, JSON.stringify(content), 'utf8');
},
}),
);

const consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation();

await run({
verbose: false,
profile: undefined,
credentialsProcessOutput: false,
force: false,
skipExpiryCheck: false,
});

expect(consoleErrorSpy).toHaveBeenCalledWith('WARN:', `Workaround: go to mystarturl, click 'Sign out', then re-run this command with --force`);
});

test('run, handles expired latest cache file', async () => {
const configLines = ['[default]', 'sso_role_name = myssorolename', 'sso_account_id = myssoaccountid', ''];
fs.writeFileSync(path.join(os.homedir(), '.aws/config'), configLines.join('\n'), 'utf8');
Expand Down
12 changes: 12 additions & 0 deletions src/tests/ssoConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,16 @@ describe('findSSOConfigFromAWSConfig', () => {

await expect(findSSOConfigFromAWSConfig(undefined)).rejects.toThrow(MissingSSOConfigError);
});

test('sso_start_url is set', async () => {
mockfs({
[path.join(os.homedir(), '.aws/config')]: ['[default]', 'sso_role_name = my_role_name', 'sso_account_id = 123456789012', 'sso_start_url = my_start_url'].join('\n'),
});

expect(await findSSOConfigFromAWSConfig(undefined)).toEqual({
roleName: 'my_role_name',
accountId: '123456789012',
startUrl: 'my_start_url',
});
});
});

0 comments on commit 7c6f6a2

Please sign in to comment.