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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 207 additions & 4 deletions cli/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,8 +44,29 @@ const TOKEN_REFRESH_BUFFER_MS = TOKEN_REFRESH_BUFFER_MINUTES * 60 * 1000;
*/
let inFlightRefresh: Promise<void> | null = null;

/** Authenticate with username/password and cache tokens. */
export async function login(username: string, password: string): Promise<void> {
/**
* 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<string>;

/**
* 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<void> {
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 });
Expand All @@ -56,11 +80,77 @@ export async function login(username: string, password: string): Promise<void> {
},
}));

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<AuthResult> {
if (!promptNewPassword) {
throw new CliError(
'This account requires a new password on first login. '
+ 'Run `bgagent login --username <email>` 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,
Expand Down Expand Up @@ -158,3 +248,116 @@ async function refreshToken(creds: Credentials): Promise<void> {
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<void> {
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<string> {
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;
}
2 changes: 2 additions & 0 deletions cli/src/bin/bgagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ program

program.addCommand(makeConfigureCommand());
program.addCommand(makeLoginCommand());
program.addCommand(makeChangePasswordCommand());
program.addCommand(makeSubmitCommand());
program.addCommand(makeListCommand());
program.addCommand(makeStatusCommand());
Expand Down
33 changes: 15 additions & 18 deletions cli/src/cognito-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -152,28 +152,25 @@ export async function adminSetPermanentPassword(
}));
}

/** Create user + set permanent password; surfaces half-failure diagnostics. */
/**
* Create a Cognito user with a *temporary* password (#238).
*
* ``adminCreateUser`` sets ``TemporaryPassword``, which lands the user in
* ``FORCE_CHANGE_PASSWORD`` state. We deliberately do **not** promote it to a
* permanent password: on their first ``bgagent login`` Cognito returns the
* ``NEW_PASSWORD_REQUIRED`` challenge and the CLI prompts the teammate to set a
* password only they know — so the admin-generated string (which lives in
* terminal scrollback / a Slack message) stops being a valid credential once
* they log in. An admin who needs a login-ready permanent password (no
* first-login prompt) can still use ``bgagent admin reset-password``.
*/
export async function adminInviteUser(
ctx: CognitoAdminContext,
email: string,
password: string,
temporaryPassword: string,
): Promise<void> {
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(
Expand Down
14 changes: 10 additions & 4 deletions cli/src/commands/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>', 'Email address of the new user (Cognito username)')
.option('--password <pwd>', 'Permanent password (default: auto-generated)')
.option('--password <pwd>', 'Temporary password (default: auto-generated)')
.option('--temp-password <pwd>', 'Alias for --password')
.action(async (email: string, opts) => {
assertLikelyEmail(email);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -264,9 +264,12 @@ 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:';
const lines = [
`email: ${email}`,
`password: ${password}`,
`${passwordLabel} ${password}`,
];
if (bundle) {
lines.push(`bundle: ${bundle}`, '');
Expand All @@ -276,6 +279,9 @@ function writeCredentialsFile(
} 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);
Expand Down
Loading
Loading