diff --git a/cli/README.md b/cli/README.md index f3cb52910..b7667fb20 100644 --- a/cli/README.md +++ b/cli/README.md @@ -73,6 +73,18 @@ bgagent login \ Tokens are saved to `~/.bgagent/credentials.json` (mode 0600). The CLI automatically refreshes expired tokens using the cached refresh token. +**First login (invited users):** `admin invite-user` issues a *temporary* password, so your first login is a rotation. Cognito returns a `NEW_PASSWORD_REQUIRED` challenge and the CLI prompts you to set (and confirm) a permanent password, which replaces the admin-shared temp one. Run it interactively — **omit `--password`** so the CLI can prompt; passing `--password` (or piping on a non-TTY) skips the rotation prompt and fails with a clear "log in interactively" error rather than hanging. Temporary passwords also expire after a few days; if yours has lapsed the login reports that and asks the admin to re-run `invite-user`. + +### `bgagent change-password` + +Rotate the signed-in user's Cognito password (requires an active `bgagent login` session). + +``` +bgagent change-password +``` + +Prompts for your current password, then the new password twice (masked). Cognito enforces the pool's password policy on the new value — by default minimum 12 characters with an upper, lower, digit, and symbol. No flags: the current-password prompt doubles as verification, and the username is read from your cached session so you never retype your email. + ### `bgagent submit` Submit a new coding task. @@ -341,7 +353,7 @@ Manage Cognito users with operator AWS credentials (`cognito-idp:Admin*` on the ``` bgagent admin invite-user \ --stack-name backgroundagent-dev \ - --password # optional; auto-generated if omitted + --password # optional temporary password; auto-generated if omitted bgagent admin list-users \ --output @@ -352,7 +364,7 @@ bgagent admin reset-password \ --password # optional; auto-generated if omitted ``` -`invite-user` creates the user, sets a permanent password, and writes credentials plus an optional configure bundle to `~/.bgagent/invites/.txt` (mode 0600). Replaces Quick Start Step 5 raw `aws cognito-idp` commands. +`invite-user` creates the user with a *temporary* password (rotated on the teammate's first login via `bgagent login`) and writes credentials plus an optional configure bundle to `~/.bgagent/invites/.txt` (mode 0600). The temp password stops being valid once they set their own, so the admin-shared string never becomes a standing credential. `reset-password` instead sets a *permanent* password for an existing user (no first-login rotation). Replaces Quick Start Step 5 raw `aws cognito-idp` commands. ## Output formats diff --git a/cli/src/auth.ts b/cli/src/auth.ts index 091fc8a4d..f2bf7dcfd 100644 --- a/cli/src/auth.ts +++ b/cli/src/auth.ts @@ -19,8 +19,11 @@ import { AuthFlowType, + ChallengeNameType, + ChangePasswordCommand, CognitoIdentityProviderClient, InitiateAuthCommand, + RespondToAuthChallengeCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { loadConfig, loadCredentials, saveCredentials } from './config'; import { debug } from './debug'; @@ -41,26 +44,132 @@ const TOKEN_REFRESH_BUFFER_MS = TOKEN_REFRESH_BUFFER_MINUTES * 60 * 1000; */ let inFlightRefresh: Promise | null = null; -/** Authenticate with username/password and cache tokens. */ -export async function login(username: string, password: string): Promise { +/** + * Prompt callback invoked when Cognito returns the ``NEW_PASSWORD_REQUIRED`` + * challenge on first login. Returning the new password lets ``login`` respond + * to the challenge without pulling TTY/readline concerns into the auth layer. + * The command layer supplies the interactive implementation. + */ +export type NewPasswordPrompt = () => Promise; + +/** + * Authenticate with username/password and cache tokens. + * + * First-login rotation: admins invite users with a *temporary* password, so + * the initial ``InitiateAuth`` returns a ``NEW_PASSWORD_REQUIRED`` challenge + * instead of tokens. When ``promptNewPassword`` is supplied, we prompt for a + * replacement, answer the challenge via ``RespondToAuthChallenge``, and persist + * the resulting tokens. Without a prompt (e.g. ``--password`` passed + * non-interactively) we surface a clear error rather than hanging. + */ +export async function login( + username: string, + password: string, + promptNewPassword?: NewPasswordPrompt, +): Promise { const config = loadConfig(); debug(`Cognito region: ${config.region}, client_id: ${config.client_id}, user_pool_id: ${config.user_pool_id}`); const client = new CognitoIdentityProviderClient({ region: config.region }); - const result = await client.send(new InitiateAuthCommand({ - AuthFlow: AuthFlowType.USER_PASSWORD_AUTH, - ClientId: config.client_id, - AuthParameters: { - USERNAME: username, - PASSWORD: password, - }, - })); + let result; + try { + result = await client.send(new InitiateAuthCommand({ + AuthFlow: AuthFlowType.USER_PASSWORD_AUTH, + ClientId: config.client_id, + AuthParameters: { + USERNAME: username, + PASSWORD: password, + }, + })); + } catch (err) { + // Invited users sit in FORCE_CHANGE_PASSWORD under Cognito's default 7-day + // TemporaryPasswordValidityDays. Once that window lapses the temp password + // is dead and Cognito answers with a bare NotAuthorizedException — the same + // error a genuinely wrong password produces, with nothing to say the temp + // one merely expired. Point the teammate at the fix (a fresh invite) rather + // than letting them retype a password that can never work again. We do not + // include the attempted password or any secret in the message. + if (err instanceof Error && err.name === 'NotAuthorizedException') { + throw new CliError( + 'Login failed: incorrect password, or your temporary password expired. ' + + 'Temporary passwords lapse after a few days — ask your admin to re-run ' + + '`bgagent admin invite-user` for a fresh one.', + ); + } + throw err; + } - const auth = result.AuthenticationResult; + if (result.ChallengeName === ChallengeNameType.NEW_PASSWORD_REQUIRED) { + const auth = await respondToNewPasswordChallenge( + client, + config.client_id, + username, + result.Session, + promptNewPassword, + ); + persistAuthResult(auth); + return; + } + + persistAuthResult(result.AuthenticationResult); +} + +/** + * Answer the first-login ``NEW_PASSWORD_REQUIRED`` challenge: prompt for a new + * password (via the caller-supplied prompt) and exchange it for tokens. + * ``ChallengeResponses`` carries the same ``USERNAME`` Cognito challenged plus + * the ``NEW_PASSWORD``; the ``Session`` echoes the value from ``InitiateAuth``. + */ +async function respondToNewPasswordChallenge( + client: CognitoIdentityProviderClient, + clientId: string, + username: string, + session: string | undefined, + promptNewPassword?: NewPasswordPrompt, +): Promise { + if (!promptNewPassword) { + throw new CliError( + 'This account requires a new password on first login. ' + + 'Run `bgagent login --username ` interactively (omit --password) ' + + 'so the CLI can prompt you to set one.', + ); + } + + const newPassword = await promptNewPassword(); + try { + const challengeResult = await client.send(new RespondToAuthChallengeCommand({ + ClientId: clientId, + ChallengeName: ChallengeNameType.NEW_PASSWORD_REQUIRED, + Session: session, + ChallengeResponses: { + USERNAME: username, + NEW_PASSWORD: newPassword, + }, + })); + return challengeResult.AuthenticationResult; + } catch (err) { + // Cognito rejects a policy-violating new password with + // InvalidPasswordException; surface the server's guidance verbatim rather + // than leaking a raw SDK stack. + if (err instanceof Error && err.name === 'InvalidPasswordException') { + throw new CliError(`New password rejected: ${err.message}`); + } + throw err; + } +} + +/** Shared shape of the tokens both ``InitiateAuth`` and challenge responses return. */ +type AuthResult = { + IdToken?: string; + RefreshToken?: string; + ExpiresIn?: number; +} | undefined; + +/** Validate and persist a Cognito auth result to the credentials cache. */ +function persistAuthResult(auth: AuthResult): void { if (!auth?.IdToken || !auth.RefreshToken || !auth.ExpiresIn) { throw new CliError('Unexpected authentication response from Cognito.'); } - const expiry = new Date(Date.now() + auth.ExpiresIn * 1000).toISOString(); saveCredentials({ id_token: auth.IdToken, @@ -158,3 +267,116 @@ async function refreshToken(creds: Credentials): Promise { throw new CliError(`Token refresh failed (${detail}). Retry, or run \`bgagent login\` if it persists.`); } } + +/** + * Rotate the current user's Cognito password. + * + * ``ChangePassword`` requires a Cognito **access token**, which the CLI does + * not persist (only the ID + refresh tokens the REST authorizer needs). Rather + * than widen the on-disk credential surface, we re-authenticate with the + * supplied *current* password to mint a short-lived access token in memory. + * That re-auth doubles as verification of the current password: a wrong one + * fails here with a clear "current password is incorrect" message before any + * change is attempted. Cognito enforces the password policy on the new value + * server-side; a policy violation surfaces as ``InvalidPasswordException``. + * + * Requires an existing ``bgagent login`` session — the username is read from + * the cached ID token so the user does not re-type their email. + */ +export async function changePassword( + currentPassword: string, + newPassword: string, +): Promise { + const config = loadConfig(); + const username = usernameFromSession(); + const client = new CognitoIdentityProviderClient({ region: config.region }); + + const accessToken = await accessTokenFor(client, config.client_id, username, currentPassword); + + try { + await client.send(new ChangePasswordCommand({ + AccessToken: accessToken, + PreviousPassword: currentPassword, + ProposedPassword: newPassword, + })); + } catch (err) { + if (err instanceof Error && err.name === 'InvalidPasswordException') { + // Cognito's message already states which policy rule failed. + throw new CliError(`New password rejected: ${err.message}`); + } + if (err instanceof Error && err.name === 'LimitExceededException') { + throw new CliError('Too many password-change attempts. Wait a few minutes and try again.'); + } + throw err; + } +} + +/** + * Read the signed-in user's Cognito username from the cached ID token. + * + * Requires a ``bgagent login`` session. The username lives in the ``email`` + * claim (the pool's sign-in alias); older tokens may only carry + * ``cognito:username``. Never logs the token or its claims. + */ +function usernameFromSession(): string { + const creds = loadCredentials(); + if (!creds) { + throw new CliError('Not authenticated. Run `bgagent login` first.'); + } + const JWT_SEGMENTS = 3; // header.payload.signature + const parts = creds.id_token.split('.'); + if (parts.length !== JWT_SEGMENTS) { + throw new CliError('Credentials file is corrupt. Run `bgagent login` to re-authenticate.'); + } + let payload: { 'email'?: string; 'cognito:username'?: string }; + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); + } catch { + throw new CliError('Credentials file is corrupt. Run `bgagent login` to re-authenticate.'); + } + const username = payload.email ?? payload['cognito:username']; + if (!username) { + throw new CliError('Could not read your account identity. Run `bgagent login` to re-authenticate.'); + } + return username; +} + +/** + * Re-authenticate to obtain a fresh, in-memory access token for + * ``ChangePassword``. A wrong current password fails here with + * ``NotAuthorizedException`` — surfaced as an actionable message. + */ +async function accessTokenFor( + client: CognitoIdentityProviderClient, + clientId: string, + username: string, + currentPassword: string, +): Promise { + let result; + try { + result = await client.send(new InitiateAuthCommand({ + AuthFlow: AuthFlowType.USER_PASSWORD_AUTH, + ClientId: clientId, + AuthParameters: { + USERNAME: username, + PASSWORD: currentPassword, + }, + })); + } catch (err) { + if (err instanceof Error && err.name === 'NotAuthorizedException') { + throw new CliError('Current password is incorrect.'); + } + throw err; + } + + const accessToken = result.AuthenticationResult?.AccessToken; + if (!accessToken) { + // A challenge (e.g. NEW_PASSWORD_REQUIRED) or an unexpected shape — the + // account is not in a state where a self-service change applies. + throw new CliError( + 'Could not verify your current password. If this is your first login, ' + + 'run `bgagent login` to set a permanent password instead.', + ); + } + return accessToken; +} diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 47a797cd7..1db40dd61 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -24,6 +24,7 @@ import { makeAdminCommand } from '../commands/admin'; import { makeApiKeyCommand } from '../commands/api-key'; import { makeApproveCommand } from '../commands/approve'; import { makeCancelCommand } from '../commands/cancel'; +import { makeChangePasswordCommand } from '../commands/change-password'; import { makeConfigureCommand } from '../commands/configure'; import { makeDenyCommand } from '../commands/deny'; import { makeEventsCommand } from '../commands/events'; @@ -66,6 +67,7 @@ program program.addCommand(makeConfigureCommand()); program.addCommand(makeLoginCommand()); +program.addCommand(makeChangePasswordCommand()); program.addCommand(makeSubmitCommand()); program.addCommand(makeListCommand()); program.addCommand(makeStatusCommand()); diff --git a/cli/src/cognito-admin.ts b/cli/src/cognito-admin.ts index e72a387b9..c0694c6d4 100644 --- a/cli/src/cognito-admin.ts +++ b/cli/src/cognito-admin.ts @@ -28,7 +28,7 @@ import { } from '@aws-sdk/client-cognito-identity-provider'; import { tryLoadConfig } from './config'; import { CliError } from './errors'; -import { DEFAULT_STACK_NAME, resolveOperatorContext } from './operator-context'; +import { resolveOperatorContext } from './operator-context'; import { getStackOutput, resolveConfigureBundleFromStack } from './stack-outputs'; import { CliConfig } from './types'; @@ -152,28 +152,19 @@ export async function adminSetPermanentPassword( })); } -/** Create user + set permanent password; surfaces half-failure diagnostics. */ +/** + * Invite a user with a *temporary* password (#238): the FORCE_CHANGE_PASSWORD + * state it leaves them in forces a first-login rotation, so the admin-generated + * string stops being a valid credential once the teammate sets their own. Kept + * as a named seam over ``adminCreateUser`` for the command layer and tests. + */ export async function adminInviteUser( ctx: CognitoAdminContext, email: string, - password: string, + temporaryPassword: string, ): Promise { const client = cognitoClient(ctx.region); - await adminCreateUser(client, ctx.userPoolId, email, password); - try { - await adminSetPermanentPassword(client, ctx.userPoolId, email, password); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const errorName = err instanceof Error ? err.name : 'Error'; - throw new CliError( - `User ${email} was created but the password could not be set ` - + `(${errorName}: ${message}). The user is now stuck in FORCE_CHANGE_PASSWORD ` - + 'state and cannot log in. Either:\n' - + ` 1. Delete and re-run: bgagent admin delete-user ${email} --stack-name ${DEFAULT_STACK_NAME}\n` - + ' 2. Or reset the password: bgagent admin reset-password ' - + `${email} --stack-name ${DEFAULT_STACK_NAME}`, - ); - } + await adminCreateUser(client, ctx.userPoolId, email, temporaryPassword); } export async function adminDeleteUser( diff --git a/cli/src/commands/admin.ts b/cli/src/commands/admin.ts index fb93c0917..050f9a4dd 100644 --- a/cli/src/commands/admin.ts +++ b/cli/src/commands/admin.ts @@ -130,9 +130,9 @@ export function makeAdminCommand(): Command { admin.addCommand( addAdminContextOptions( new Command('invite-user') - .description('Create a Cognito user with a permanent password and optional configure bundle') + .description('Create a Cognito user with a temporary password (rotated on first login) and optional configure bundle') .argument('', 'Email address of the new user (Cognito username)') - .option('--password ', 'Permanent password (default: auto-generated)') + .option('--password ', 'Temporary password (default: auto-generated)') .option('--temp-password ', 'Alias for --password') .action(async (email: string, opts) => { assertLikelyEmail(email); @@ -228,7 +228,7 @@ function printInviteSummary(email: string, password: string, bundle: string | nu const bar = '─'.repeat(SUMMARY_BAR_WIDTH); console.log(); console.log(`✓ Created Cognito user ${email}`); - console.log('✓ Set permanent password (no first-login change required)'); + console.log('✓ Set temporary password (teammate is prompted to set a permanent one on first login)'); if (bundle) { console.log('✓ Included configure bundle for `bgagent configure --from-bundle`'); } else { @@ -264,18 +264,28 @@ function writeCredentialsFile( const inviteDir = path.join(getConfigDir(), 'invites'); fs.mkdirSync(inviteDir, { recursive: true, mode: 0o700 }); const invitePath = credentialsFilePath(email); + // Invites issue a temporary password the teammate rotates on first login; + // password-reset issues a permanent one. Label it so the recipient knows. + const passwordLabel = kind === 'invite' ? 'temp password:' : 'password:'; + // Pad every label to the widest one (`temp password:` = 14) so the value + // column lines up regardless of which labels are present — the aligned block + // the USER_GUIDE advertises. + const LABEL_WIDTH = 'temp password:'.length; const lines = [ - `email: ${email}`, - `password: ${password}`, + `${'email:'.padEnd(LABEL_WIDTH)} ${email}`, + `${passwordLabel.padEnd(LABEL_WIDTH)} ${password}`, ]; if (bundle) { - lines.push(`bundle: ${bundle}`, ''); + lines.push(`${'bundle:'.padEnd(LABEL_WIDTH)} ${bundle}`, ''); lines.push('Run:'); lines.push(` bgagent configure --from-bundle ${bundle}`); lines.push(` bgagent login --username ${email}`); } else if (kind === 'password-reset') { lines.push('', 'Run:', ` bgagent login --username ${email}`); } + if (kind === 'invite') { + lines.push('', 'On first login you will be prompted to set a permanent password.'); + } lines.push(''); fs.writeFileSync(invitePath, lines.join('\n'), { mode: SECRET_FILE_MODE }); fs.chmodSync(invitePath, SECRET_FILE_MODE); diff --git a/cli/src/commands/change-password.ts b/cli/src/commands/change-password.ts new file mode 100644 index 000000000..c179ce3e4 --- /dev/null +++ b/cli/src/commands/change-password.ts @@ -0,0 +1,65 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Command } from 'commander'; +import { changePassword } from '../auth'; +import { CliError } from '../errors'; +import { promptSecret } from '../prompt-secret'; + +/** + * Prompt for a new password twice and confirm the two entries match. Shared by + * ``bgagent change-password`` and the first-login ``NEW_PASSWORD_REQUIRED`` + * challenge in ``bgagent login`` so both use the same masked, confirmed prompt. + * + * Cognito enforces the real password policy server-side; we only guard against + * a typo (mismatch) and an empty entry here — leaking policy specifics client + * side would drift from the pool config. + */ +export async function promptNewPasswordWithConfirmation(): Promise { + const next = await promptSecret('New password: '); + if (!next) { + throw new CliError('New password cannot be empty.'); + } + const confirm = await promptSecret('Confirm new password: '); + if (next !== confirm) { + throw new CliError('Passwords do not match.'); + } + return next; +} + +/** + * ``bgagent change-password`` — rotate the signed-in user's Cognito password. + * + * Interactive: prompts for the current password, then the new password twice. + * Delegates to ``changePassword`` in the auth layer, which verifies the current + * password and lets Cognito enforce the password policy on the new one. + */ +export function makeChangePasswordCommand(): Command { + return new Command('change-password') + .description('Change your Cognito password (requires an active `bgagent login` session)') + .action(async () => { + const currentPassword = await promptSecret('Current password: '); + if (!currentPassword) { + throw new CliError('Current password cannot be empty.'); + } + const newPassword = await promptNewPasswordWithConfirmation(); + await changePassword(currentPassword, newPassword); + console.log('Password changed successfully.'); + }); +} diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index e0f00b6c5..66a755c76 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -21,6 +21,7 @@ import * as readline from 'readline'; import { Command } from 'commander'; import { login } from '../auth'; import { debug } from '../debug'; +import { promptNewPasswordWithConfirmation } from './change-password'; export function makeLoginCommand(): Command { return new Command('login') @@ -30,7 +31,22 @@ export function makeLoginCommand(): Command { .action(async (opts) => { debug(`Logging in as: ${opts.username}`); const password = opts.password || await promptPassword(); - await login(opts.username, password); + // First-login rotation: invites now issue a *temporary* password, so + // Cognito challenges with NEW_PASSWORD_REQUIRED. Pass an interactive + // prompt so the user can set a permanent password inline; `login` + // answers the challenge and persists the resulting tokens. + // + // Gate the prompt on *real* interactivity. Passing it unconditionally + // makes the guard in `login` unreachable, so a first-login account driven + // non-interactively (`--password` on a pipe / CI) would block forever on + // the readline prompt with no terminal to answer it. When stdin is not a + // TTY or `--password` was supplied, hand `login` no prompt so it raises + // the clear "log in interactively" error instead of hanging. + const interactive = Boolean(process.stdin.isTTY) && !opts.password; + await login(opts.username, password, interactive ? () => { + console.log('This account requires a new password on first login.'); + return promptNewPasswordWithConfirmation(); + } : undefined); console.log('Login successful. Credentials saved.'); }); } @@ -82,13 +98,21 @@ function promptPassword(): Promise { process.stdin.on('data', onData); } else { - // Non-TTY (piped input): read a single line + // Non-TTY (piped input): read a single line. Resolve *before* closing — + // `rl.close()` emits `'close'` synchronously, so the close-reject would + // otherwise win the race and reject with "No password provided." even + // when a line was read. `resolved` also stops the close handler firing + // when the stream ends normally after a successful read. + let resolved = false; rl.once('line', (line) => { - rl.close(); + resolved = true; resolve(line); + rl.close(); }); rl.once('close', () => { - reject(new Error('No password provided.')); + if (!resolved) { + reject(new Error('No password provided.')); + } }); } }); diff --git a/cli/test/auth.test.ts b/cli/test/auth.test.ts index 24ab4fecd..fc61127ff 100644 --- a/cli/test/auth.test.ts +++ b/cli/test/auth.test.ts @@ -20,17 +20,30 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { getAuthToken, login } from '../src/auth'; +import { changePassword, getAuthToken, login } from '../src/auth'; import { saveConfig, saveCredentials } from '../src/config'; -// Mock the Cognito SDK +// Mock the Cognito SDK. Each command constructor tags its params with a +// ``__command`` discriminator so tests can drive ``mockSend`` per command type +// (InitiateAuth vs RespondToAuthChallenge vs ChangePassword) instead of relying +// on brittle call-order alone. const mockSend = jest.fn(); jest.mock('@aws-sdk/client-cognito-identity-provider', () => ({ CognitoIdentityProviderClient: jest.fn().mockImplementation(() => ({ send: mockSend })), - InitiateAuthCommand: jest.fn().mockImplementation((params) => params), + InitiateAuthCommand: jest.fn().mockImplementation((params) => ({ __command: 'InitiateAuth', ...params })), + RespondToAuthChallengeCommand: jest.fn().mockImplementation((params) => ({ __command: 'RespondToAuthChallenge', ...params })), + ChangePasswordCommand: jest.fn().mockImplementation((params) => ({ __command: 'ChangePassword', ...params })), AuthFlowType: { USER_PASSWORD_AUTH: 'USER_PASSWORD_AUTH', REFRESH_TOKEN_AUTH: 'REFRESH_TOKEN_AUTH' }, + ChallengeNameType: { NEW_PASSWORD_REQUIRED: 'NEW_PASSWORD_REQUIRED' }, })); +/** Build a base64url-encoded ID token carrying the given claims (no signature). */ +function fakeIdToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${header}.${payload}.sig`; +} + describe('auth', () => { let tmpDir: string; @@ -76,6 +89,167 @@ describe('auth', () => { mockSend.mockResolvedValue({ AuthenticationResult: null }); await expect(login('user@example.com', 'pass')).rejects.toThrow('Unexpected authentication response'); }); + + test('maps NotAuthorizedException to an expired-temp-password hint (no secret leaked)', async () => { + // Invited users lapse into a dead temp password after Cognito's default + // 7-day window; Cognito answers with a bare NotAuthorizedException. The + // CLI should point at a fresh invite, not let the user retype a doomed + // password — and must not echo the attempted password. + mockSend.mockRejectedValue( + Object.assign(new Error('Incorrect username or password.'), { name: 'NotAuthorizedException' }), + ); + const err = (await login('user@example.com', 'ExpiredTemp1!').catch((e: Error) => e)) as Error; + expect(err.message).toMatch(/temporary password expired/); + expect(err.message).toContain('bgagent admin invite-user'); + expect(err.message).not.toContain('ExpiredTemp1!'); + }); + + test('rethrows non-auth InitiateAuth errors unchanged', async () => { + mockSend.mockRejectedValue( + Object.assign(new Error('getaddrinfo ENOTFOUND cognito-idp'), { name: 'TypeError' }), + ); + await expect(login('user@example.com', 'pass')).rejects.toThrow('getaddrinfo ENOTFOUND'); + }); + }); + + describe('login — NEW_PASSWORD_REQUIRED first-login challenge', () => { + test('prompts for a new password, responds to the challenge, and persists tokens', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.resolve({ ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-abc' }); + } + if (cmd.__command === 'RespondToAuthChallenge') { + return Promise.resolve({ + AuthenticationResult: { IdToken: 'new-id', RefreshToken: 'new-refresh', ExpiresIn: 3600 }, + }); + } + throw new Error(`unexpected command ${cmd.__command}`); + }); + + const prompt = jest.fn().mockResolvedValue('N3w$trongPass!'); + await login('user@example.com', 'TempPass1!', prompt); + + expect(prompt).toHaveBeenCalledTimes(1); + // The challenge response must carry the challenged USERNAME + NEW_PASSWORD + // and echo the InitiateAuth Session. + const challengeCall = mockSend.mock.calls + .map((c) => c[0]) + .find((c: { __command: string }) => c.__command === 'RespondToAuthChallenge'); + expect(challengeCall).toMatchObject({ + ChallengeName: 'NEW_PASSWORD_REQUIRED', + Session: 'sess-abc', + ChallengeResponses: { USERNAME: 'user@example.com', NEW_PASSWORD: 'N3w$trongPass!' }, + }); + + const creds = JSON.parse(fs.readFileSync(path.join(tmpDir, 'credentials.json'), 'utf-8')); + expect(creds.id_token).toBe('new-id'); + expect(creds.refresh_token).toBe('new-refresh'); + }); + + test('errors clearly when the challenge fires but no prompt is supplied (non-interactive)', async () => { + mockSend.mockResolvedValue({ ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-abc' }); + await expect(login('user@example.com', 'TempPass1!')).rejects.toThrow( + /requires a new password on first login/, + ); + }); + + test('surfaces Cognito password-policy rejection on the new password', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.resolve({ ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-abc' }); + } + return Promise.reject( + Object.assign(new Error('Password did not conform with policy: minimum length 12'), { + name: 'InvalidPasswordException', + }), + ); + }); + + const prompt = jest.fn().mockResolvedValue('weak'); + const err = (await login('user@example.com', 'TempPass1!', prompt).catch((e: Error) => e)) as Error; + expect(err.message).toContain('New password rejected'); + expect(err.message).toContain('minimum length 12'); + }); + }); + + describe('changePassword', () => { + beforeEach(() => { + // A valid session is required; username comes from the ID token's email claim. + saveCredentials({ + id_token: fakeIdToken({ email: 'user@example.com', sub: 'uuid-1' }), + refresh_token: 'refresh-token', + token_expiry: new Date(Date.now() + 3600_000).toISOString(), + }); + }); + + test('re-authenticates with the current password, then changes it (no persisted access token)', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.resolve({ AuthenticationResult: { AccessToken: 'acc-token' } }); + } + if (cmd.__command === 'ChangePassword') { + return Promise.resolve({}); + } + throw new Error(`unexpected command ${cmd.__command}`); + }); + + await changePassword('OldPass1!', 'N3w$trongPass!'); + + const changeCall = mockSend.mock.calls + .map((c) => c[0]) + .find((c: { __command: string }) => c.__command === 'ChangePassword'); + expect(changeCall).toMatchObject({ + AccessToken: 'acc-token', + PreviousPassword: 'OldPass1!', + ProposedPassword: 'N3w$trongPass!', + }); + + // The access token is used in-memory only, never written to disk. + const creds = JSON.parse(fs.readFileSync(path.join(tmpDir, 'credentials.json'), 'utf-8')); + expect(creds.access_token).toBeUndefined(); + }); + + test('wrong current password surfaces a clear error and never calls ChangePassword', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.reject( + Object.assign(new Error('Incorrect username or password.'), { name: 'NotAuthorizedException' }), + ); + } + throw new Error(`unexpected command ${cmd.__command}`); + }); + + await expect(changePassword('WrongPass1!', 'N3w$trongPass!')).rejects.toThrow( + 'Current password is incorrect.', + ); + const changeCalled = mockSend.mock.calls + .map((c) => c[0]) + .some((c: { __command: string }) => c.__command === 'ChangePassword'); + expect(changeCalled).toBe(false); + }); + + test('weak new password surfaces the Cognito policy error', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.resolve({ AuthenticationResult: { AccessToken: 'acc-token' } }); + } + return Promise.reject( + Object.assign(new Error('Password does not conform to policy: not long enough'), { + name: 'InvalidPasswordException', + }), + ); + }); + + const err = (await changePassword('OldPass1!', 'weak').catch((e: Error) => e)) as Error; + expect(err.message).toContain('New password rejected'); + expect(err.message).toContain('not long enough'); + }); + + test('no active session yields a clear "not authenticated" error', async () => { + fs.rmSync(path.join(tmpDir, 'credentials.json'), { force: true }); + await expect(changePassword('OldPass1!', 'N3w$trongPass!')).rejects.toThrow('Not authenticated'); + expect(mockSend).not.toHaveBeenCalled(); + }); }); describe('getAuthToken', () => { diff --git a/cli/test/commands/admin.test.ts b/cli/test/commands/admin.test.ts index c0a41afb6..2db7d2306 100644 --- a/cli/test/commands/admin.test.ts +++ b/cli/test/commands/admin.test.ts @@ -17,10 +17,26 @@ * SOFTWARE. */ -import { decodeBundle, encodeBundle, generateTempPassword } from '../../src/commands/admin'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { decodeBundle, encodeBundle, generateTempPassword, makeAdminCommand } from '../../src/commands/admin'; import { CliError } from '../../src/errors'; import { CliConfig } from '../../src/types'; +// Mock the Cognito admin layer so `invite-user` resolves a fixed context and +// performs no AWS calls — the assertion is on the local credentials file the +// command writes, not on Cognito. +jest.mock('../../src/cognito-admin', () => ({ + ...jest.requireActual('../../src/cognito-admin'), + resolveCognitoAdminContext: jest.fn().mockResolvedValue({ + region: 'us-east-1', + userPoolId: 'us-east-1_abc', + configureBundle: null, + }), + adminInviteUser: jest.fn().mockResolvedValue(undefined), +})); + describe('admin bundle helpers', () => { const sampleConfig: CliConfig = { api_url: 'https://abc123.execute-api.us-east-1.amazonaws.com/v1', @@ -106,3 +122,48 @@ describe('generateTempPassword', () => { expect(seen.size).toBeGreaterThanOrEqual(19); }); }); + +describe('admin invite-user credentials file', () => { + let tmpDir: string; + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bgagent-invite-test-')); + process.env.BGAGENT_CONFIG_DIR = tmpDir; + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + delete process.env.BGAGENT_CONFIG_DIR; + fs.rmSync(tmpDir, { recursive: true, force: true }); + consoleSpy.mockRestore(); + }); + + test('writes an aligned block with the temp-password label and first-login note', async () => { + const admin = makeAdminCommand(); + await admin.parseAsync([ + 'node', 'admin', + 'invite-user', 'teammate@example.com', + '--password', 'K9$mPq2nL!vXf3Hb', + ]); + + const invitePath = path.join(tmpDir, 'invites', 'teammate@example.com.txt'); + const body = fs.readFileSync(invitePath, 'utf-8'); + const lines = body.split('\n'); + + // The label is "temp password:" (not "password:") for invites. + const emailLine = lines.find((l) => l.startsWith('email:'))!; + const pwdLine = lines.find((l) => l.startsWith('temp password:'))!; + expect(pwdLine).toContain('K9$mPq2nL!vXf3Hb'); + expect(lines.some((l) => l.startsWith('password:'))).toBe(false); + + // All labels pad to the widest ("temp password:" = 14), so the value column + // lines up: every value begins at offset 15 (label width + one space). + const VALUE_COLUMN = 'temp password:'.length + 1; + expect(emailLine.indexOf('teammate@example.com')).toBe(VALUE_COLUMN); + expect(pwdLine.indexOf('K9$mPq2nL!vXf3Hb')).toBe(VALUE_COLUMN); + + // The trailing first-login guidance the docs promise. + expect(body).toContain('On first login you will be prompted to set a permanent password.'); + }); +}); diff --git a/cli/test/commands/change-password.test.ts b/cli/test/commands/change-password.test.ts new file mode 100644 index 000000000..e0fbca0b3 --- /dev/null +++ b/cli/test/commands/change-password.test.ts @@ -0,0 +1,124 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { changePassword } from '../../src/auth'; +import { makeChangePasswordCommand, promptNewPasswordWithConfirmation } from '../../src/commands/change-password'; +import { CliError } from '../../src/errors'; +import { promptSecret } from '../../src/prompt-secret'; + +jest.mock('../../src/auth'); +jest.mock('../../src/prompt-secret'); + +const mockChangePassword = changePassword as jest.MockedFunction; +const mockPromptSecret = promptSecret as jest.MockedFunction; + +/** Queue prompt answers in the order the command reads them. */ +function queuePrompts(...answers: string[]): void { + mockPromptSecret.mockReset(); + for (const a of answers) { + mockPromptSecret.mockResolvedValueOnce(a); + } +} + +describe('change-password command', () => { + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + process.exitCode = undefined; + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + mockChangePassword.mockReset(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + process.exitCode = undefined; + }); + + test('prompts current + new (twice), calls changePassword, and reports success', async () => { + queuePrompts('OldPass1!', 'N3w$trongPass!', 'N3w$trongPass!'); + mockChangePassword.mockResolvedValue(); + + await makeChangePasswordCommand().parseAsync(['node', 'change-password']); + + expect(mockChangePassword).toHaveBeenCalledWith('OldPass1!', 'N3w$trongPass!'); + expect(consoleSpy).toHaveBeenCalledWith('Password changed successfully.'); + }); + + test('rejects when the two new-password entries do not match (no change attempted)', async () => { + queuePrompts('OldPass1!', 'N3w$trongPass!', 'typo-mismatch'); + + await expect(makeChangePasswordCommand().parseAsync(['node', 'change-password'])).rejects.toThrow( + 'Passwords do not match.', + ); + expect(mockChangePassword).not.toHaveBeenCalled(); + }); + + test('rejects an empty current password (no change attempted)', async () => { + queuePrompts(''); + + await expect(makeChangePasswordCommand().parseAsync(['node', 'change-password'])).rejects.toThrow( + 'Current password cannot be empty.', + ); + expect(mockChangePassword).not.toHaveBeenCalled(); + }); + + test('surfaces a weak-new-password policy error from the auth layer', async () => { + queuePrompts('OldPass1!', 'weak', 'weak'); + mockChangePassword.mockRejectedValue( + new CliError('New password rejected: Password did not conform with policy.'), + ); + + await expect(makeChangePasswordCommand().parseAsync(['node', 'change-password'])).rejects.toThrow( + /New password rejected/, + ); + }); + + test('propagates a no-session error from the auth layer', async () => { + queuePrompts('OldPass1!', 'N3w$trongPass!', 'N3w$trongPass!'); + mockChangePassword.mockRejectedValue(new CliError('Not authenticated. Run `bgagent login` first.')); + + await expect(makeChangePasswordCommand().parseAsync(['node', 'change-password'])).rejects.toThrow( + 'Not authenticated', + ); + }); +}); + +describe('promptNewPasswordWithConfirmation', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('returns the value when both entries match', async () => { + queuePrompts('N3w$trongPass!', 'N3w$trongPass!'); + await expect(promptNewPasswordWithConfirmation()).resolves.toBe('N3w$trongPass!'); + }); + + test('rejects an empty new password', async () => { + queuePrompts(''); + await expect(promptNewPasswordWithConfirmation()).rejects.toThrow('New password cannot be empty.'); + }); + + test('rejects on mismatch', async () => { + queuePrompts('a-strong-one', 'a-different-one'); + await expect(promptNewPasswordWithConfirmation()).rejects.toThrow('Passwords do not match.'); + }); +}); diff --git a/cli/test/commands/cognito-admin.test.ts b/cli/test/commands/cognito-admin.test.ts index 2c319f486..c4cb432d1 100644 --- a/cli/test/commands/cognito-admin.test.ts +++ b/cli/test/commands/cognito-admin.test.ts @@ -278,35 +278,44 @@ describe('adminCreateUser', () => { }); }); -describe('adminInviteUser', () => { +describe('adminInviteUser (#238 — temporary password, first-login rotation)', () => { beforeEach(() => { cognitoSend.mockReset(); }); - test('creates user then sets permanent password', async () => { - cognitoSend - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({ Users: [{ Username: 'a@b.com' }] }) - .mockResolvedValueOnce({}); + test('creates the user with a TEMPORARY password and never sets a permanent one', async () => { + cognitoSend.mockResolvedValueOnce({}); + await adminInviteUser( { region: 'us-east-1', userPoolId: 'pool', configureBundle: null }, 'a@b.com', 'SecretPass123!', ); - expect(cognitoSend).toHaveBeenCalledTimes(3); + + // Exactly one call: AdminCreateUser. No AdminSetUserPassword (which would + // promote to a permanent password and skip the first-login challenge). + expect(cognitoSend).toHaveBeenCalledTimes(1); + const input = cognitoSend.mock.calls[0][0].input; + expect(input).toEqual(expect.objectContaining({ + Username: 'a@b.com', + TemporaryPassword: 'SecretPass123!', + MessageAction: 'SUPPRESS', + })); + // Guard against a regression that re-introduces a permanent password. + expect(input).not.toHaveProperty('Permanent'); }); - test('surfaces half-created user when password set fails', async () => { - cognitoSend - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({ Users: [{ Username: 'a@b.com' }] }) - .mockRejectedValueOnce(Object.assign(new Error('policy'), { name: 'InvalidPasswordException' })); + test('propagates UsernameExistsException as a CliError', async () => { + cognitoSend.mockRejectedValueOnce( + Object.assign(new Error('exists'), { name: 'UsernameExistsException' }), + ); await expect(adminInviteUser( { region: 'us-east-1', userPoolId: 'pool', configureBundle: null }, 'a@b.com', - 'weak', - )).rejects.toThrow(/FORCE_CHANGE_PASSWORD/); + 'SecretPass123!', + )).rejects.toThrow(/already exists/); + expect(cognitoSend).toHaveBeenCalledTimes(1); }); }); diff --git a/cli/test/commands/login.test.ts b/cli/test/commands/login.test.ts index a4bd95208..0a34dfc3d 100644 --- a/cli/test/commands/login.test.ts +++ b/cli/test/commands/login.test.ts @@ -26,8 +26,36 @@ import { saveConfig } from '../../src/config'; const mockSend = jest.fn(); jest.mock('@aws-sdk/client-cognito-identity-provider', () => ({ CognitoIdentityProviderClient: jest.fn().mockImplementation(() => ({ send: mockSend })), - InitiateAuthCommand: jest.fn().mockImplementation((params) => params), + InitiateAuthCommand: jest.fn().mockImplementation((params) => ({ __command: 'InitiateAuth', ...params })), + RespondToAuthChallengeCommand: jest.fn().mockImplementation((params) => ({ __command: 'RespondToAuthChallenge', ...params })), + ChangePasswordCommand: jest.fn().mockImplementation((params) => ({ __command: 'ChangePassword', ...params })), AuthFlowType: { USER_PASSWORD_AUTH: 'USER_PASSWORD_AUTH', REFRESH_TOKEN_AUTH: 'REFRESH_TOKEN_AUTH' }, + ChallengeNameType: { NEW_PASSWORD_REQUIRED: 'NEW_PASSWORD_REQUIRED' }, +})); + +// Stub the confirmed-password prompt so the interactive first-login path can be +// exercised without a real TTY. +const mockPromptNewPassword = jest.fn(); +jest.mock('../../src/commands/change-password', () => ({ + promptNewPasswordWithConfirmation: () => mockPromptNewPassword(), +})); + +// Controllable readline so the non-TTY `promptPassword` path (piped stdin) can +// be driven deterministically — including the resolve-before-close ordering. +type RlHandlers = { line?: (line: string) => void; close?: () => void }; +const rlHandlers: RlHandlers = {}; +let rlCloseEmitsSynchronously = false; +jest.mock('readline', () => ({ + createInterface: jest.fn().mockImplementation(() => ({ + once: (event: string, handler: (...a: unknown[]) => void) => { + if (event === 'line') rlHandlers.line = handler as (line: string) => void; + if (event === 'close') rlHandlers.close = handler as () => void; + }, + close: () => { + // Mirror readline: 'close' fires synchronously from close(). + if (rlCloseEmitsSynchronously) rlHandlers.close?.(); + }, + })), })); describe('login command', () => { @@ -45,6 +73,10 @@ describe('login command', () => { }); consoleSpy = jest.spyOn(console, 'log').mockImplementation(); mockSend.mockReset(); + mockPromptNewPassword.mockReset(); + rlHandlers.line = undefined; + rlHandlers.close = undefined; + rlCloseEmitsSynchronously = true; }); afterEach(() => { @@ -77,4 +109,142 @@ describe('login command', () => { expect(creds.access_token).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith('Login successful. Credentials saved.'); }); + + test('piped password (non-TTY, no --password): resolves before close so the race does not reject', async () => { + // The regression: promptPassword closed the readline *before* resolving, and + // 'close' fires synchronously and rejects "No password provided." — so a + // successfully-read line was lost. With resolve-before-close a piped + // password logs in cleanly. + const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + mockSend.mockResolvedValue({ + AuthenticationResult: { IdToken: 'piped-id', RefreshToken: 'piped-ref', ExpiresIn: 3600 }, + }); + + try { + const cmd = makeLoginCommand(); + const done = cmd.parseAsync(['node', 'test', '--username', 'user@example.com']); + // Deliver the piped line; promptPassword then calls rl.close() (which our + // mock fires synchronously) — resolve must have already won. + rlHandlers.line?.('PipedPass1!'); + await done; + + const creds = JSON.parse(fs.readFileSync(path.join(tmpDir, 'credentials.json'), 'utf-8')); + expect(creds.id_token).toBe('piped-id'); + const initiate = mockSend.mock.calls + .map((c) => c[0]) + .find((c: { __command: string }) => c.__command === 'InitiateAuth'); + expect(initiate.AuthParameters.PASSWORD).toBe('PipedPass1!'); + } finally { + if (isTTYDescriptor) Object.defineProperty(process.stdin, 'isTTY', isTTYDescriptor); + else Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + } + }); + + test('piped stdin closes with no line: rejects "No password provided."', async () => { + const isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + try { + const cmd = makeLoginCommand(); + const done = cmd.parseAsync(['node', 'test', '--username', 'user@example.com']); + rlHandlers.close?.(); + await expect(done).rejects.toThrow('No password provided.'); + } finally { + if (isTTYDescriptor) Object.defineProperty(process.stdin, 'isTTY', isTTYDescriptor); + else Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + } + }); + + // Command-layer seam test crossing login.ts → auth.ts. The auth-layer + // "no callback" case tests a shape the CLI must actually produce; this proves + // the command does not hand `login` a prompt when the account challenges for + // a new password on a non-interactive invocation (`--password`), so the guard + // in `login` fires with a clear error instead of hanging on stdin. This is + // the seam that regressed when the callback was passed unconditionally. + test('first-login challenge + non-interactive (--password) throws the guard error and never reads stdin', async () => { + mockSend.mockResolvedValue({ ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-abc' }); + // If the command wrongly reached a stdin prompt this would hang the test; + // spy so any read attempt is an assertable failure rather than a timeout. + const stdinOn = jest.spyOn(process.stdin, 'on'); + const stdinResume = jest.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + + const cmd = makeLoginCommand(); + await expect( + cmd.parseAsync([ + 'node', 'test', + '--username', 'user@example.com', + '--password', 'ExpiredTemp1!', + ]), + ).rejects.toThrow(/requires a new password on first login/); + + // The callback console line must not have been emitted, and no stdin + // 'data' listener should have been attached to answer a prompt. + expect(consoleSpy).not.toHaveBeenCalledWith('This account requires a new password on first login.'); + expect(stdinResume).not.toHaveBeenCalled(); + expect(stdinOn.mock.calls.some((c) => c[0] === 'data')).toBe(false); + + stdinOn.mockRestore(); + stdinResume.mockRestore(); + }); + + test('interactive first-login (TTY, no --password) prompts for a new password and answers the challenge', async () => { + mockSend.mockImplementation((cmd: { __command: string }) => { + if (cmd.__command === 'InitiateAuth') { + return Promise.resolve({ ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: 'sess-abc' }); + } + if (cmd.__command === 'RespondToAuthChallenge') { + return Promise.resolve({ + AuthenticationResult: { IdToken: 'rotated-id', RefreshToken: 'rotated-ref', ExpiresIn: 3600 }, + }); + } + throw new Error(`unexpected command ${cmd.__command}`); + }); + mockPromptNewPassword.mockResolvedValue('N3w$trongPass!'); + + // Force the interactive branch: TTY present, --password absent — this is the + // only path that supplies the new-password callback to `login`. The initial + // masked `promptPassword` reads from raw-mode stdin, so fake a TTY stdin and + // feed the temp password through the 'data' handler it registers. Assigning + // the methods directly (rather than jest.spyOn) is required because a piped + // test stdin has no `setRawMode` to spy on. + const original: Record = {}; + const patch = (key: string, value: unknown): void => { + original[key] = (process.stdin as unknown as Record)[key]; + Object.defineProperty(process.stdin, key, { value, configurable: true }); + }; + const restore = (): void => { + for (const [key, value] of Object.entries(original)) { + Object.defineProperty(process.stdin, key, { value, configurable: true }); + } + }; + patch('isTTY', true); + patch('setRawMode', () => process.stdin); + patch('resume', () => process.stdin); + patch('pause', () => process.stdin); + patch('removeListener', () => process.stdin); + patch('on', (event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + // Deliver the temp password + Enter so promptPassword resolves. + setImmediate(() => handler(Buffer.from('TempPass1!\n'))); + } + return process.stdin; + }); + + try { + const cmd = makeLoginCommand(); + await cmd.parseAsync(['node', 'test', '--username', 'user@example.com']); + + expect(mockPromptNewPassword).toHaveBeenCalledTimes(1); + const challengeCall = mockSend.mock.calls + .map((c) => c[0]) + .find((c: { __command: string }) => c.__command === 'RespondToAuthChallenge'); + expect(challengeCall).toMatchObject({ + ChallengeResponses: { USERNAME: 'user@example.com', NEW_PASSWORD: 'N3w$trongPass!' }, + }); + const creds = JSON.parse(fs.readFileSync(path.join(tmpDir, 'credentials.json'), 'utf-8')); + expect(creds.id_token).toBe('rotated-id'); + } finally { + restore(); + } + }); }); diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 423bd63ac..780c85c14 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -111,11 +111,12 @@ The CLI shows a picker of human Linear members in the workspace. After you pick The teammate needs their own ABCA account first (Cognito user + configured CLI). If they don't have one yet: 1. **Admin** runs `bgagent admin invite-user teammate@example.com` to create their Cognito user (see [User guide → Joining an existing deployment](./USER_GUIDE.md#joining-an-existing-deployment) for the full Cognito-side flow). -2. **Teammate** pastes the bundle + password from the admin into: +2. **Teammate** pastes the bundle + temp password from the admin into: ```bash bgagent configure --from-bundle bgagent login --username teammate@example.com + # first login prompts you to set a permanent password (temp password is one-time) ``` 3. **Teammate** redeems the Linear invite code: diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 2e5588edc..02d4dbcee 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -92,17 +92,17 @@ Three steps: ``` ✓ Created Cognito user your-email@example.com - ✓ Set permanent password (no first-login change required) + ✓ Set temporary password (teammate is prompted to set a permanent one on first login) Share with the new teammate: ──────────────────────────────────────────────────────────────── - email: your-email@example.com - password: K9$mPq2nL!vXf3Hb - bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… + email: your-email@example.com + temp password: K9$mPq2nL!vXf3Hb + bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… ──────────────────────────────────────────────────────────────── ``` - The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. + The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. The **temp password is a one-time credential** — you'll replace it on first login (below), after which the admin-shared string is no longer valid. 2. **Configure your CLI from the bundle:** @@ -110,17 +110,21 @@ Three steps: bgagent configure --from-bundle ``` -3. **Log in with the temp password:** +3. **Log in and set your permanent password:** ```bash bgagent login --username your-email@example.com - # paste the temp password + # paste the temp password when prompted for "Password:" + # then, because this is your first login, you'll be prompted to set a + # new (permanent) password and confirm it ``` - The CLI caches your tokens in `~/.bgagent/credentials.json` and auto-refreshes them. + On first login Cognito requires you to rotate the admin-generated temp password. The CLI prompts `New password:` + `Confirm new password:`, sets it, and caches your tokens in `~/.bgagent/credentials.json` (auto-refreshed thereafter). Subsequent logins just use your permanent password. Do this interactively — omit `--password` so the CLI can prompt you. You're in. `bgagent submit`, `bgagent list`, `bgagent status` work against the shared stack. Tasks you submit are attributed to your Cognito user; concurrency caps and budgets are scoped to you. +Want to rotate your password later? Run `bgagent change-password` (prompts for your current password, then the new one twice). Cognito enforces the pool's password policy — minimum 12 characters with an upper, lower, digit, and symbol. + **You do not run** `bgagent linear setup`, `bgagent jira setup`, `bgagent jira app-setup`, or `bgagent slack setup` — those are workspace-level operations performed once by the stack/workspace admin. If you want Linear- or Jira-triggered tasks to be attributed to *you* (not auto-dropped), the admin needs to map your Linear identity or Jira account to your Cognito user; ask them about [Linear user linking](./LINEAR_SETUP_GUIDE.md#inviting-teammates) or [Jira user linking](./JIRA_SETUP_GUIDE.md#6-link-your-jira-identity). If something looks broken (commands fail with `Not configured` or `401 Unauthorized`), re-paste the bundle and re-run `bgagent login`. The bundle holds no secrets — your password (separate) is the credential. @@ -149,20 +153,20 @@ APP_CLIENT_ID=$(aws cloudformation describe-stacks --stack-name backgroundagent- bgagent admin invite-user teammate@example.com ``` -This wraps Cognito `admin-create-user` + `admin-set-user-password` with the right defaults (email-verified, password set as permanent so the teammate doesn't hit a password-change flow on first login, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong temp password. Send the bundle + password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username ` and they're in. +This wraps Cognito `admin-create-user` with the right defaults (email-verified so the account is usable immediately, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong **temporary** password. Send the bundle + temp password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username `, and on that first login Cognito prompts them to set a permanent password only they know. That first-login rotation means the credential you shared over Slack/email stops being valid once they're in — the admin-generated string is transient by design. -The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` and `cognito-idp:AdminSetUserPassword` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. +The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. (No `AdminSetUserPassword` is issued for invites; the teammate rotates their own password on first login. `bgagent admin reset-password` still uses `AdminSetUserPassword` to set a permanent password when an admin must recover an account.) **Pool constraints** (enforced server-side; the CLI handles them, but useful to know if you ever need to bypass it with raw AWS CLI): - **Username MUST be an email address.** The pool is configured with email as the sign-in alias. - **Password policy**: minimum 12 characters, with at least one uppercase, lowercase, digit, and symbol. -- **`email_verified=true` attribute is required**, otherwise the account stays in `FORCE_CHANGE_PASSWORD` state and `initiate-auth` fails with `User is not confirmed`. +- **`email_verified=true` attribute is required.** An invited user sits in `FORCE_CHANGE_PASSWORD` state by design (they rotate the temp password on first login); with `email_verified` set, `initiate-auth` returns the `NEW_PASSWORD_REQUIRED` challenge and login proceeds through it. Without it, `initiate-auth` fails with `User is not confirmed`. - **`--message-action SUPPRESS`** stops Cognito from trying to email the temp password — required unless you've set up SES verified identities. #### Raw AWS CLI fallback -If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying calls are: +If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying call is: ```bash aws cognito-idp admin-create-user \ @@ -172,16 +176,20 @@ aws cognito-idp admin-create-user \ --user-attributes Name=email,Value=user@example.com Name=email_verified,Value=true \ --temporary-password 'TempPass123!@' \ --message-action SUPPRESS - -aws cognito-idp admin-set-user-password \ - --region "$REGION" \ - --user-pool-id $USER_POOL_ID \ - --username user@example.com \ - --password 'YourPerm@nent1Pass!' \ - --permanent ``` -The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so the teammate does not have to go through a password change flow on first login. After running these, hand the teammate the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). +This creates the user with a **temporary** password (state `FORCE_CHANGE_PASSWORD`) and pre-verifies the email — matching what `bgagent admin invite-user` does. The teammate rotates the temp password on their first `bgagent login`. Hand them the temp password plus the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). + +> If you deliberately want a login-ready **permanent** password with no first-login prompt (e.g. a service account), add a second call — but for human teammates prefer the rotate-on-first-login default above: +> +> ```bash +> aws cognito-idp admin-set-user-password \ +> --region "$REGION" \ +> --user-pool-id $USER_POOL_ID \ +> --username user@example.com \ +> --password 'YourPerm@nent1Pass!' \ +> --permanent +> ``` ### Obtain a JWT token diff --git a/docs/src/content/docs/using/Authentication.md b/docs/src/content/docs/using/Authentication.md index 063f74950..25ea38c99 100644 --- a/docs/src/content/docs/using/Authentication.md +++ b/docs/src/content/docs/using/Authentication.md @@ -53,17 +53,17 @@ Three steps: ``` ✓ Created Cognito user your-email@example.com - ✓ Set permanent password (no first-login change required) + ✓ Set temporary password (teammate is prompted to set a permanent one on first login) Share with the new teammate: ──────────────────────────────────────────────────────────────── - email: your-email@example.com - password: K9$mPq2nL!vXf3Hb - bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… + email: your-email@example.com + temp password: K9$mPq2nL!vXf3Hb + bundle: eyJhcGlfdXJsIjoiaHR0cHM6Ly9hYmMxMjM… ──────────────────────────────────────────────────────────────── ``` - The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. + The `bundle` is a base64 blob carrying the four config fields (API URL, region, user pool ID, app client ID) so you don't have to type them as separate flags. The **temp password is a one-time credential** — you'll replace it on first login (below), after which the admin-shared string is no longer valid. 2. **Configure your CLI from the bundle:** @@ -71,17 +71,21 @@ Three steps: bgagent configure --from-bundle ``` -3. **Log in with the temp password:** +3. **Log in and set your permanent password:** ```bash bgagent login --username your-email@example.com - # paste the temp password + # paste the temp password when prompted for "Password:" + # then, because this is your first login, you'll be prompted to set a + # new (permanent) password and confirm it ``` - The CLI caches your tokens in `~/.bgagent/credentials.json` and auto-refreshes them. + On first login Cognito requires you to rotate the admin-generated temp password. The CLI prompts `New password:` + `Confirm new password:`, sets it, and caches your tokens in `~/.bgagent/credentials.json` (auto-refreshed thereafter). Subsequent logins just use your permanent password. Do this interactively — omit `--password` so the CLI can prompt you. You're in. `bgagent submit`, `bgagent list`, `bgagent status` work against the shared stack. Tasks you submit are attributed to your Cognito user; concurrency caps and budgets are scoped to you. +Want to rotate your password later? Run `bgagent change-password` (prompts for your current password, then the new one twice). Cognito enforces the pool's password policy — minimum 12 characters with an upper, lower, digit, and symbol. + **You do not run** `bgagent linear setup`, `bgagent jira setup`, `bgagent jira app-setup`, or `bgagent slack setup` — those are workspace-level operations performed once by the stack/workspace admin. If you want Linear- or Jira-triggered tasks to be attributed to *you* (not auto-dropped), the admin needs to map your Linear identity or Jira account to your Cognito user; ask them about [Linear user linking](/sample-autonomous-cloud-coding-agents/using/linear-setup-guide#inviting-teammates) or [Jira user linking](/sample-autonomous-cloud-coding-agents/using/jira-setup-guide#6-link-your-jira-identity). If something looks broken (commands fail with `Not configured` or `401 Unauthorized`), re-paste the bundle and re-run `bgagent login`. The bundle holds no secrets — your password (separate) is the credential. @@ -110,20 +114,20 @@ APP_CLIENT_ID=$(aws cloudformation describe-stacks --stack-name backgroundagent- bgagent admin invite-user teammate@example.com ``` -This wraps Cognito `admin-create-user` + `admin-set-user-password` with the right defaults (email-verified, password set as permanent so the teammate doesn't hit a password-change flow on first login, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong temp password. Send the bundle + password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username ` and they're in. +This wraps Cognito `admin-create-user` with the right defaults (email-verified so the account is usable immediately, suppress-email so SES isn't required) and prints a shareable config bundle plus an auto-generated strong **temporary** password. Send the bundle + temp password to the teammate; they paste them into `bgagent configure --from-bundle ` + `bgagent login --username `, and on that first login Cognito prompts them to set a permanent password only they know. That first-login rotation means the credential you shared over Slack/email stops being valid once they're in — the admin-generated string is transient by design. -The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` and `cognito-idp:AdminSetUserPassword` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. +The CLI command requires the running shell to have AWS credentials with `cognito-idp:AdminCreateUser` on the configured user pool — i.e. you're acting as the stack admin, not as a Cognito-authenticated end-user. (No `AdminSetUserPassword` is issued for invites; the teammate rotates their own password on first login. `bgagent admin reset-password` still uses `AdminSetUserPassword` to set a permanent password when an admin must recover an account.) **Pool constraints** (enforced server-side; the CLI handles them, but useful to know if you ever need to bypass it with raw AWS CLI): - **Username MUST be an email address.** The pool is configured with email as the sign-in alias. - **Password policy**: minimum 12 characters, with at least one uppercase, lowercase, digit, and symbol. -- **`email_verified=true` attribute is required**, otherwise the account stays in `FORCE_CHANGE_PASSWORD` state and `initiate-auth` fails with `User is not confirmed`. +- **`email_verified=true` attribute is required.** An invited user sits in `FORCE_CHANGE_PASSWORD` state by design (they rotate the temp password on first login); with `email_verified` set, `initiate-auth` returns the `NEW_PASSWORD_REQUIRED` challenge and login proceeds through it. Without it, `initiate-auth` fails with `User is not confirmed`. - **`--message-action SUPPRESS`** stops Cognito from trying to email the temp password — required unless you've set up SES verified identities. #### Raw AWS CLI fallback -If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying calls are: +If you can't run `bgagent admin invite-user` (e.g., you're scripting this from CI without the CLI installed), the underlying call is: ```bash aws cognito-idp admin-create-user \ @@ -133,16 +137,20 @@ aws cognito-idp admin-create-user \ --user-attributes Name=email,Value=user@example.com Name=email_verified,Value=true \ --temporary-password 'TempPass123!@' \ --message-action SUPPRESS - -aws cognito-idp admin-set-user-password \ - --region "$REGION" \ - --user-pool-id $USER_POOL_ID \ - --username user@example.com \ - --password 'YourPerm@nent1Pass!' \ - --permanent ``` -The first command creates the user with a temporary password and pre-verifies the email. The second sets a permanent password so the teammate does not have to go through a password change flow on first login. After running these, hand the teammate the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). +This creates the user with a **temporary** password (state `FORCE_CHANGE_PASSWORD`) and pre-verifies the email — matching what `bgagent admin invite-user` does. The teammate rotates the temp password on their first `bgagent login`. Hand them the temp password plus the four config fields manually (or build the bundle: `echo '{"api_url":"…","region":"…","user_pool_id":"…","client_id":"…"}' | base64`). + +> If you deliberately want a login-ready **permanent** password with no first-login prompt (e.g. a service account), add a second call — but for human teammates prefer the rotate-on-first-login default above: +> +> ```bash +> aws cognito-idp admin-set-user-password \ +> --region "$REGION" \ +> --user-pool-id $USER_POOL_ID \ +> --username user@example.com \ +> --password 'YourPerm@nent1Pass!' \ +> --permanent +> ``` ### Obtain a JWT token diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 31a3f5fa7..62f2b9244 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -115,11 +115,12 @@ The CLI shows a picker of human Linear members in the workspace. After you pick The teammate needs their own ABCA account first (Cognito user + configured CLI). If they don't have one yet: 1. **Admin** runs `bgagent admin invite-user teammate@example.com` to create their Cognito user (see [User guide → Joining an existing deployment](/sample-autonomous-cloud-coding-agents/using/overview#joining-an-existing-deployment) for the full Cognito-side flow). -2. **Teammate** pastes the bundle + password from the admin into: +2. **Teammate** pastes the bundle + temp password from the admin into: ```bash bgagent configure --from-bundle bgagent login --username teammate@example.com + # first login prompts you to set a permanent password (temp password is one-time) ``` 3. **Teammate** redeems the Linear invite code: