From 7c6f6a2b012f8abc2477ee27c6c2825b8e06a2b2 Mon Sep 17 00:00:00 2001 From: Andrew Plummer Date: Tue, 15 Aug 2023 12:57:57 +0100 Subject: [PATCH] Issue 58: credentials expiry warning and workaround --- src/main.ts | 85 ++++++++++++++++++++++++------------- src/ssoConfig.ts | 12 +++++- src/tests/main.test.ts | 83 +++++++++++++++++++++++++++++++++++- src/tests/ssoConfig.test.ts | 12 ++++++ 4 files changed, 160 insertions(+), 32 deletions(-) diff --git a/src/main.ts b/src/main.ts index cf0fade..0dd04d3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,8 @@ import { hasKey } from './utils'; const execPromise = util.promisify(exec); +const jsSdkExpiryWindowSeconds = 15 * 60; + interface RunProps { verbose: boolean; profile: string | undefined; @@ -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; @@ -74,7 +84,7 @@ interface SSOLoginContext { } const runSsoLogin = async (context: SSOLoginContext): Promise => { - 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, @@ -83,42 +93,42 @@ const runSsoLogin = async (context: SSOLoginContext): Promise => { }); 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 => { 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, @@ -130,11 +140,11 @@ export const run = async (props: RunProps): Promise => { 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 = { @@ -148,9 +158,9 @@ export const run = async (props: RunProps): Promise => { 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 { @@ -158,25 +168,40 @@ export const run = async (props: RunProps): Promise => { } } - 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): Promise => { diff --git a/src/ssoConfig.ts b/src/ssoConfig.ts index a93237b..a22fcad 100644 --- a/src/ssoConfig.ts +++ b/src/ssoConfig.ts @@ -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 => { @@ -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, }; }; diff --git a/src/tests/main.test.ts b/src/tests/main.test.ts index 32b93f8..23800f2 100644 --- a/src/tests/main.test.ts +++ b/src/tests/main.test.ts @@ -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) { @@ -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', }; @@ -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; + 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; + 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'); diff --git a/src/tests/ssoConfig.test.ts b/src/tests/ssoConfig.test.ts index ca96f8c..07a2470 100644 --- a/src/tests/ssoConfig.test.ts +++ b/src/tests/ssoConfig.test.ts @@ -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', + }); + }); });