UNPKG

agentlang

Version:

The easiest way to build the most reliable AI agents - enterprise-grade teams of AI agents that collaborate with each other and humans

1,408 lines (1,265 loc) 48.4 kB
import { AgentlangAuth, InviteUserCallback, InvitationInfo, LoginCallback, LogoutCallback, SessionInfo, SignUpCallback, UserInfo, } from './interface.js'; import { ensureUser, ensureUserRoles, ensureUserSession, findUser, findUserByEmail, } from '../modules/auth.js'; import { logger } from '../logger.js'; import { sleepMilliseconds, generateUrlSafePassword } from '../util.js'; import { Instance } from '../module.js'; import { CognitoJwtVerifier } from 'aws-jwt-verify'; import { Environment } from '../interpreter.js'; import { isNodeEnv } from '../../utils/runtime.js'; import { UnauthorisedError, UserNotFoundError, UserNotConfirmedError, PasswordResetRequiredError, TooManyRequestsError, InvalidParameterError, ExpiredCodeError, CodeMismatchError, BadRequestError, } from '../defs.js'; let fromEnv: any = undefined; let CognitoIdentityProviderClient: any = undefined; let SignUpCommand: any = undefined; let ConfirmSignUp: any = undefined; let ResendConfirmationCodeCommand: any = undefined; let ForgotPasswordCommand: any = undefined; let ConfirmForgotPasswordCommand: any = undefined; let AdminGetUserCommand: any = undefined; let InitiateAuthCommand: any = undefined; let AdminCreateUserCommand: any = undefined; let AdminDisableUserCommand: any = undefined; let AdminEnableUserCommand: any = undefined; let AdminDeleteUserCommand: any = undefined; let RespondToAuthChallengeCommand: any = undefined; let AuthenticationDetails: any = undefined; let CognitoUser: any = undefined; let CognitoUserPool: any = undefined; let CognitoUserSession: any = undefined; let CognitoIdToken: any = undefined; let CognitoAccessToken: any = undefined; let CognitoRefreshToken: any = undefined; if (isNodeEnv) { const cp = await import('@aws-sdk/credential-providers'); fromEnv = cp.fromEnv; const cip = await import('@aws-sdk/client-cognito-identity-provider'); CognitoIdentityProviderClient = cip.CognitoIdentityProviderClient; SignUpCommand = cip.SignUpCommand; ConfirmSignUp = cip.ConfirmSignUpCommand; ResendConfirmationCodeCommand = cip.ResendConfirmationCodeCommand; ForgotPasswordCommand = cip.ForgotPasswordCommand; ConfirmForgotPasswordCommand = cip.ConfirmForgotPasswordCommand; AdminGetUserCommand = cip.AdminGetUserCommand; InitiateAuthCommand = cip.InitiateAuthCommand; AdminCreateUserCommand = cip.AdminCreateUserCommand; AdminDisableUserCommand = cip.AdminDisableUserCommand; AdminEnableUserCommand = cip.AdminEnableUserCommand; AdminDeleteUserCommand = cip.AdminDeleteUserCommand; RespondToAuthChallengeCommand = cip.RespondToAuthChallengeCommand; const ci = await import('amazon-cognito-identity-js'); AuthenticationDetails = ci.AuthenticationDetails; CognitoUser = ci.CognitoUser; CognitoUserPool = ci.CognitoUserPool; CognitoUserSession = ci.CognitoUserSession; CognitoIdToken = ci.CognitoIdToken; CognitoAccessToken = ci.CognitoAccessToken; CognitoRefreshToken = ci.CognitoRefreshToken; } const defaultConfig = isNodeEnv ? new Map<string, string | undefined>() .set('UserPoolId', process.env.COGNITO_USER_POOL_ID) .set('ClientId', process.env.COGNITO_CLIENT_ID) : new Map(); // Helper function to parse Cognito error and throw appropriate custom error function handleCognitoError(err: any, context: string): never { // Log error details for debugging (sanitize sensitive information) const sanitizedMessage = sanitizeErrorMessage(err.message || ''); logger.error(`Cognito error in ${context}: ${err.name} - ${sanitizedMessage}`, { errorName: err.name, errorCode: err.code, context: context, statusCode: err.$metadata?.httpStatusCode, }); // Handle specific Cognito errors with user-friendly messages switch (err.name) { case 'UserNotFoundException': logger.debug(`User not found in context: ${context}`); throw new UserNotFoundError('User account not found. Please check your email or sign up.'); case 'NotAuthorizedException': // Check if this is a password-related error vs other auth issues if (err.message && err.message.includes('password')) { logger.debug(`Invalid password attempt in context: ${context}`); throw new UnauthorisedError('Invalid password. Please try again.'); } else if (err.message && err.message.includes('not confirmed')) { logger.debug(`User not confirmed in context: ${context}`); throw new UserNotConfirmedError(); } else { logger.debug(`Authentication failed in context: ${context}`); throw new UnauthorisedError('Authentication failed. Please check your credentials.'); } case 'UserNotConfirmedException': logger.debug(`User not confirmed in context: ${context}`); throw new UserNotConfirmedError(); case 'PasswordResetRequiredException': logger.debug(`Password reset required in context: ${context}`); throw new PasswordResetRequiredError(); case 'TooManyRequestsException': logger.warn(`Rate limit exceeded in context: ${context}`); throw new TooManyRequestsError(); case 'TooManyFailedAttemptsException': logger.warn(`Too many failed attempts in context: ${context}`); throw new TooManyRequestsError('Too many failed login attempts. Please try again later.'); case 'InvalidParameterException': logger.debug(`Invalid parameters in context: ${context}`); throw new InvalidParameterError( sanitizeErrorMessage(err.message) || 'Invalid parameters provided' ); case 'ExpiredCodeException': logger.debug(`Expired code in context: ${context}`); throw new ExpiredCodeError(); case 'CodeMismatchException': logger.debug(`Code mismatch in context: ${context}`); throw new CodeMismatchError(); case 'UsernameExistsException': logger.debug(`Username exists in context: ${context}`); throw new BadRequestError('An account with this email already exists.'); case 'InvalidPasswordException': logger.debug(`Invalid password format in context: ${context}`); throw new BadRequestError( 'Password does not meet requirements. It must be at least 8 characters long and contain uppercase, lowercase, numbers, and special characters.' ); case 'LimitExceededException': logger.warn(`Service limit exceeded in context: ${context}`); throw new TooManyRequestsError('Service limit exceeded. Please try again later.'); case 'InternalErrorException': logger.error(`Internal Cognito error in context: ${context}`); throw new Error('Authentication service is temporarily unavailable. Please try again later.'); case 'ResourceNotFoundException': logger.error(`Resource not found in context: ${context}`); throw new Error('Authentication service configuration error. Please contact support.'); case 'AliasExistsException': logger.debug(`Alias exists in context: ${context}`); throw new BadRequestError('An account with this email already exists.'); case 'InvalidEmailRoleAccessPolicyException': logger.error(`Invalid email role access policy in context: ${context}`); throw new Error('Email service configuration error. Please contact support.'); case 'UserLambdaValidationException': logger.error(`User lambda validation error in context: ${context}`); throw new BadRequestError('User validation failed. Please check your input and try again.'); case 'UnsupportedUserStateException': logger.debug(`Unsupported user state in context: ${context}`); throw new UserNotConfirmedError( 'User account is in an unsupported state. Please contact support.' ); case 'MFAMethodNotFoundException': logger.debug(`MFA method not found in context: ${context}`); throw new BadRequestError('MFA method not found. Please set up MFA and try again.'); case 'CodeDeliveryFailureException': logger.error(`Code delivery failure in context: ${context}`); throw new Error('Unable to deliver verification code. Please try again later.'); case 'DuplicateProviderException': logger.error(`Duplicate provider in context: ${context}`); throw new BadRequestError('Authentication provider already exists.'); case 'EnableSoftwareTokenMFAException': logger.debug(`Software token MFA required in context: ${context}`); throw new BadRequestError('Software token MFA setup required.'); case 'ForbiddenException': logger.warn(`Forbidden access in context: ${context}`); throw new UnauthorisedError('Access forbidden. Please check your permissions.'); case 'GroupExistsException': logger.debug(`Group exists in context: ${context}`); throw new BadRequestError('Group already exists.'); case 'InvalidLambdaResponseException': logger.error(`Invalid lambda response in context: ${context}`); throw new Error('Authentication service error. Please try again later.'); case 'InvalidOAuthFlowException': logger.error(`Invalid OAuth flow in context: ${context}`); throw new BadRequestError('Invalid OAuth flow. Please try again.'); case 'InvalidSmsRoleAccessPolicyException': logger.error(`Invalid SMS role access policy in context: ${context}`); throw new Error('SMS service configuration error. Please contact support.'); case 'InvalidSmsRoleTrustRelationshipException': logger.error(`Invalid SMS role trust relationship in context: ${context}`); throw new Error('SMS service configuration error. Please contact support.'); case 'InvalidUserPoolConfigurationException': logger.error(`Invalid user pool configuration in context: ${context}`); throw new Error('Authentication service configuration error. Please contact support.'); case 'PreconditionNotMetException': logger.debug(`Precondition not met in context: ${context}`); throw new BadRequestError('Precondition not met. Please check your request and try again.'); case 'ScopeDoesNotExistException': logger.error(`Scope does not exist in context: ${context}`); throw new BadRequestError('Invalid scope. Please check your request.'); case 'UnexpectedLambdaException': logger.error(`Unexpected lambda exception in context: ${context}`); throw new Error('Authentication service error. Please try again later.'); case 'UserImportInProgressException': logger.warn(`User import in progress in context: ${context}`); throw new TooManyRequestsError('User import in progress. Please try again later.'); case 'UserPoolTaggingException': logger.error(`User pool tagging exception in context: ${context}`); throw new Error('Authentication service configuration error. Please contact support.'); default: // For any other errors, throw a generic error with sanitized message logger.error(`Unhandled Cognito error: ${err.name}`, { errorName: err.name, errorCode: err.code, context: context, }); throw new Error( `Authentication error: ${sanitizeErrorMessage(err.message) || 'An unexpected error occurred'}` ); } } // Helper function to sanitize error messages to prevent sensitive information exposure function sanitizeErrorMessage(message: string): string { if (!message) return ''; // Remove any potential sensitive information patterns return message .replace(/password/gi, '[REDACTED]') .replace(/token/gi, '[REDACTED]') .replace(/secret/gi, '[REDACTED]') .replace(/key/gi, '[REDACTED]') .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[EMAIL_REDACTED]') .replace(/\b[A-Fa-f0-9]{32,}\b/g, '[TOKEN_REDACTED]') .replace(/\b\d{4,}\b/g, '[NUMBER_REDACTED]'); } // Helper function to get HTTP status code for error type export function getHttpStatusForError(error: Error): number { if (error instanceof UnauthorisedError) return 401; if (error instanceof UserNotFoundError) return 404; if (error instanceof TooManyRequestsError) return 429; if (error instanceof BadRequestError) return 400; if (error instanceof InvalidParameterError) return 400; if (error instanceof UserNotConfirmedError) return 403; if (error instanceof PasswordResetRequiredError) return 403; if (error instanceof ExpiredCodeError) return 400; if (error instanceof CodeMismatchError) return 400; // Check error message for additional context if (error.message) { if ( error.message.includes('temporarily unavailable') || error.message.includes('service error') || error.message.includes('configuration error') ) { return 503; // Service Unavailable } if (error.message.includes('contact support')) { return 500; // Internal Server Error } } return 500; // Internal server error for unknown errors } export class CognitoAuth implements AgentlangAuth { config: Map<string, string | undefined>; userPool: any; constructor(config?: Map<string, string>) { this.config = config ? config : defaultConfig; const upid = this.config.get('UserPoolId'); if (upid) this.userPool = new CognitoUserPool({ UserPoolId: upid, ClientId: this.fetchClientId(), }); } fetchUserPoolId(): string { return this.fetchConfig('UserPoolId'); } fetchClientId(): string { return this.fetchConfig('ClientId'); } private fetchConfig(k: string): string { const id = this.config.get(k); if (id) { return id; } throw new Error(`${k} is not set`); } async signUp( firstName: string, lastName: string, username: string, password: string, userData: Map<string, string> | undefined, env: Environment, cb: SignUpCallback ): Promise<void> { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const userAttrs = [ { Name: 'email', Value: username, }, { Name: 'name', Value: username, }, { Name: 'given_name', Value: firstName, }, { Name: 'family_name', Value: lastName, }, ]; if (userData) { userData.forEach((v: string, k: string) => { userAttrs.push({ Name: k, Value: v }); }); } const input = { ClientId: this.config.get('ClientId'), Username: username, Password: password, UserAttributes: userAttrs, ValidationData: userAttrs, }; const command = new SignUpCommand(input); try { logger.debug(`Attempting signup for user: ${username}`); const response = await client.send(command); if (response.$metadata.httpStatusCode == 200) { logger.info(`Signup successful for user: ${username}`); const user = await ensureUser(username, firstName, lastName, env, 'Active'); const userInfo: UserInfo = { username: username, firstName: firstName, lastName: lastName, id: user.id, systemUserInfo: response.UserSub, }; cb(userInfo); } else { logger.error(`Signup failed with HTTP status ${response.$metadata.httpStatusCode}`, { username: username, statusCode: response.$metadata.httpStatusCode, }); throw new BadRequestError(`Signup failed with status ${response.$metadata.httpStatusCode}`); } } catch (err: any) { if (err instanceof BadRequestError) throw err; logger.error(`Signup error for user ${username}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'signUp'); } } async confirmSignup(username: string, confirmationCode: string, _: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new ConfirmSignUp({ ClientId: this.config.get('ClientId'), Username: username, ConfirmationCode: confirmationCode, }); await client.send(command); } catch (error: any) { logger.error(`Failed to confirm signup: ${error.message}`); handleCognitoError(error, 'confirmSignup'); } } async resendConfirmationCode(username: string, _: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new ResendConfirmationCodeCommand({ ClientId: this.config.get('ClientId'), Username: username, }); await client.send(command); } catch (error: any) { logger.error(`Failed to resend confirmation code: ${error.message}`); handleCognitoError(error, 'resendConfirmationCode'); } } async forgotPassword(username: string, _env: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new ForgotPasswordCommand({ ClientId: this.fetchClientId(), Username: username, }); await client.send(command); } catch (err: any) { logger.error(`Forgot password failed for ${username}: ${sanitizeErrorMessage(err.message)}`); handleCognitoError(err, 'forgotPassword'); } } async confirmForgotPassword( username: string, confirmationCode: string, newPassword: string, _env: Environment ): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new ConfirmForgotPasswordCommand({ ClientId: this.fetchClientId(), Username: username, ConfirmationCode: confirmationCode, Password: newPassword, }); await client.send(command); } catch (err: any) { logger.error( `Confirm forgot password failed for ${username}: ${sanitizeErrorMessage(err.message)}` ); handleCognitoError(err, 'confirmForgotPassword'); } } async login( username: string, password: string, env: Environment, cb: LoginCallback ): Promise<void> { // Check if Cognito is configured const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; if (cognitoConfigured) { // Cognito-first: authenticate directly with Cognito without local store dependency const user = new CognitoUser({ Username: username, Pool: this.fetchUserPool(), }); const authDetails = new AuthenticationDetails({ Username: username, Password: password, }); let result: any; let authError: any; user.authenticateUser(authDetails, { onSuccess: (session: any) => { result = session; }, onFailure: (err: any) => { logger.debug(`Cognito authentication failed for user ${username}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); authError = err; }, mfaRequired: (challengeName: any, _challengeParameters: any) => { logger.info(`MFA required for user ${username}: ${challengeName}`); authError = new Error('MFA authentication required'); }, newPasswordRequired: (_userAttributes: any, _requiredAttributes: any) => { logger.info(`New password required for user ${username}`); authError = new PasswordResetRequiredError( 'New password required. Please reset your password.' ); }, }); while (result === undefined && authError === undefined) { await sleepMilliseconds(100); } if (authError) { if (authError instanceof PasswordResetRequiredError) { throw authError; } logger.error(`Login failed for user ${username}:`, { errorName: authError.name, errorMessage: sanitizeErrorMessage(authError.message), }); handleCognitoError(authError, 'login'); } if (result) { // After successful Cognito authentication, create/update local records const idtok = result.getIdToken(); const idToken = idtok.getJwtToken(); const idTokenPayload = idtok.decodePayload(); const firstName = idTokenPayload['given_name'] || idTokenPayload['name'] || ''; const lastName = idTokenPayload['family_name'] || ''; const userGroups = idTokenPayload['cognito:groups']; let localUser = await findUserByEmail(username, env); if (!localUser) { localUser = await ensureUser(username, firstName, lastName, env); } const userid = localUser.lookup('id'); if (userGroups) { await ensureUserRoles(userid, userGroups, env); } const accessToken = result.getAccessToken().getJwtToken(); const refreshToken = result.getRefreshToken().getToken(); const localSess: Instance = await ensureUserSession( userid, idToken, accessToken, refreshToken, env ); const sessInfo: SessionInfo = { sessionId: localSess.lookup('id'), userId: userid, authToken: idToken, idToken: idToken, accessToken: accessToken, refreshToken: refreshToken, systemSesionInfo: result, }; cb(sessInfo); } else { logger.error(`Login failed for ${username} - no result received`); throw new UnauthorisedError('Login failed. Please try again.'); } } else { // Cognito not configured, fall back to local authentication let localUser = await findUserByEmail(username, env); if (!localUser) { logger.warn(`User ${username} not found in local store`); localUser = await ensureUser(username, '', '', env); } const user = new CognitoUser({ Username: username, Pool: this.fetchUserPool(), }); const authDetails = new AuthenticationDetails({ Username: username, Password: password, }); let result: any; let authError: any; user.authenticateUser(authDetails, { onSuccess: (session: any) => { result = session; }, onFailure: (err: any) => { logger.debug(`Cognito authentication failed for user ${username}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); authError = err; }, mfaRequired: (challengeName: any, _challengeParameters: any) => { logger.info(`MFA required for user ${username}: ${challengeName}`); authError = new Error('MFA authentication required'); }, newPasswordRequired: (_userAttributes: any, _requiredAttributes: any) => { logger.info(`New password required for user ${username}`); authError = new PasswordResetRequiredError( 'New password required. Please reset your password.' ); }, }); while (result === undefined && authError === undefined) { await sleepMilliseconds(100); } if (authError) { if (authError instanceof PasswordResetRequiredError) { throw authError; } logger.error(`Login failed for user ${username}:`, { errorName: authError.name, errorMessage: sanitizeErrorMessage(authError.message), }); handleCognitoError(authError, 'login'); } if (result) { const userid = localUser.lookup('id'); const idToken = result.getIdToken().getJwtToken(); const accessToken = result.getAccessToken().getJwtToken(); const refreshToken = result.getRefreshToken().getToken(); const localSess: Instance = await ensureUserSession( userid, idToken, accessToken, refreshToken, env ); const sessInfo: SessionInfo = { sessionId: localSess.lookup('id'), userId: userid, authToken: idToken, idToken: idToken, accessToken: accessToken, refreshToken: refreshToken, systemSesionInfo: result, }; cb(sessInfo); } else { logger.error(`Login failed for ${username} - no result received`); throw new UnauthorisedError('Login failed. Please try again.'); } } } async logout(sessionInfo: SessionInfo, env: Environment, cb?: LogoutCallback): Promise<void> { try { const localUser = await findUser(sessionInfo.userId, env); if (!localUser) { logger.warn(`User ${sessionInfo.userId} not found during logout`); if (cb) cb(true); return; } const user = new CognitoUser({ Username: localUser.lookup('email'), Pool: this.fetchUserPool(), }); let done = false; let logoutError: any; const session = new CognitoUserSession({ IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), }); user.setSignInUserSession(session); user.globalSignOut({ onSuccess: function () { done = true; }, onFailure: function (err: any) { done = true; logger.error(`Cognito signOut error for user ${sessionInfo.userId}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); logoutError = err; }, }); while (!done) { await sleepMilliseconds(100); } if (logoutError) { logger.error( `Error during Cognito logout for user ${sessionInfo.userId}: ${logoutError.message}` ); // Continue with local session cleanup even if Cognito logout fails } logger.debug(`Successfully logged out user ${sessionInfo.userId}`); if (cb) cb(true); } catch (err: any) { logger.error(`Logout failed for user ${sessionInfo.userId}: ${err.message}`); if (cb) cb(false); throw err; } } async changePassword( sessionInfo: SessionInfo, newPassword: string, oldPassword: string, env: Environment ): Promise<boolean> { const localUser = await findUser(sessionInfo.userId, env); if (!localUser) { logger.warn(`User ${sessionInfo.userId} not found for password-change`); return false; } const email = localUser.lookup('email'); const user = new CognitoUser({ Username: email, Pool: this.fetchUserPool(), }); const session = new CognitoUserSession({ IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), }); user.setSignInUserSession(session); let done = false; let cpErr: any = undefined; user.changePassword(oldPassword, newPassword, (err: any, _: any) => { if (err) { done = true; cpErr = err; } else { done = true; } }); while (!done) { await sleepMilliseconds(100); } if (cpErr) { logger.warn(`Failed to change the password for ${email} - ${cpErr.message}`); return false; } return true; } private fetchUserPool() { if (this.userPool) { return this.userPool; } throw new Error('UserPool not initialized'); } async verifyToken(token: string): Promise<void> { try { const verifier = CognitoJwtVerifier.create({ userPoolId: this.fetchUserPoolId(), tokenUse: 'id', clientId: this.fetchClientId(), }); const payload = await verifier.verify(token); logger.debug(`Decoded JWT for ${payload.email}`); } catch (err: any) { logger.error(`Token verification failed:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); // Handle specific token verification errors if (err.message && err.message.includes('expired')) { throw new UnauthorisedError('Token has expired. Please login again.'); } if (err.message && err.message.includes('invalid')) { throw new UnauthorisedError('Invalid token format.'); } if (err.message && err.message.includes('not before')) { throw new UnauthorisedError('Token is not yet valid.'); } if (err.message && err.message.includes('audience')) { throw new UnauthorisedError('Token audience mismatch.'); } throw new UnauthorisedError( `Token verification failed: ${sanitizeErrorMessage(err.message) || 'Invalid token'}` ); } } async getUser(userId: string, env: Environment): Promise<UserInfo> { const localUser = await findUser(userId, env); if (!localUser) { throw new UserNotFoundError(`User ${userId} not found in local database`); } const userEmail = localUser.lookup('email'); const firstName = localUser.lookup('firstName'); const lastName = localUser.lookup('lastName'); // Check if Cognito is configured const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; if (cognitoConfigured && userEmail) { try { // Get additional user details from Cognito const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new AdminGetUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: userEmail, }); const response = await client.send(command); // Return user info with both local and Cognito data return { id: userId, username: userEmail, firstName: firstName, lastName: lastName, systemUserInfo: { localUser: localUser, cognitoData: { userAttributes: response.UserAttributes, userCreateDate: response.UserCreateDate, userLastModifiedDate: response.UserLastModifiedDate, userStatus: response.UserStatus, enabled: response.Enabled, preferredMfaSetting: response.PreferredMfaSetting, userMFASettingList: response.UserMFASettingList, }, }, }; } catch (err: any) { logger.warn(`Failed to get Cognito user info for ${userEmail}, using local data only:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); // Fall back to local data only return { id: userId, username: userEmail, firstName: firstName, lastName: lastName, systemUserInfo: localUser, }; } } else { // Cognito not configured or no email, use local data only return { id: userId, username: userEmail || userId, firstName: firstName, lastName: lastName, systemUserInfo: localUser, }; } } async getUserByEmail(email: string, env: Environment): Promise<UserInfo> { const localUser = await findUserByEmail(email, env); if (!localUser) { throw new UserNotFoundError(`User with email ${email} not found in local database`); } const userId = localUser.lookup('id'); const firstName = localUser.lookup('firstName'); const lastName = localUser.lookup('lastName'); // Check if Cognito is configured const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; if (cognitoConfigured) { try { // Get additional user details from Cognito const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); const command = new AdminGetUserCommand({ UserPoolId: this.fetchUserPoolId(), ClientId: this.fetchClientId(), Username: email, }); const response = await client.send(command); // Return user info with both local and Cognito data return { id: userId, username: email, firstName: firstName, lastName: lastName, systemUserInfo: { localUser: localUser, cognitoData: { userAttributes: response.UserAttributes, userCreateDate: response.UserCreateDate, userLastModifiedDate: response.UserLastModifiedDate, userStatus: response.UserStatus, enabled: response.Enabled, preferredMfaSetting: response.PreferredMfaSetting, userMFASettingList: response.UserMFASettingList, }, }, }; } catch (err: any) { console.log(`Failed to get Cognito user info for email ${email}, using local data only:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); // Fall back to local data only return { id: userId, username: email, firstName: firstName, lastName: lastName, systemUserInfo: localUser, }; } } else { // Cognito not configured, use local data only return { id: userId, username: email, firstName: firstName, lastName: lastName, systemUserInfo: localUser, }; } } async refreshToken(refreshTokenString: string, env: Environment): Promise<SessionInfo> { try { // Use InitiateAuth with REFRESH_TOKEN_AUTH flow const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const command = new InitiateAuthCommand({ AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: this.fetchClientId(), AuthParameters: { REFRESH_TOKEN: refreshTokenString, }, }); const response = await client.send(command); if (!response.AuthenticationResult) { throw new UnauthorisedError('Token refresh failed'); } const newIdToken = response.AuthenticationResult.IdToken!; const newAccessToken = response.AuthenticationResult.AccessToken!; const newRefreshToken = response.AuthenticationResult.RefreshToken || refreshTokenString; // Extract user info from the new ID token const idTokenPayload = JSON.parse(atob(newIdToken.split('.')[1])); const userEmail = idTokenPayload.email; // Find or create local user let localUser = await findUserByEmail(userEmail, env); if (!localUser) { localUser = await ensureUser(userEmail, '', '', env); } const userId = localUser.lookup('id'); // Update local session const updatedSession = await ensureUserSession( userId, newIdToken, newAccessToken, newRefreshToken, env ); const sessInfo: SessionInfo = { sessionId: updatedSession.lookup('id'), userId: userId, authToken: newIdToken, idToken: newIdToken, accessToken: newAccessToken, refreshToken: newRefreshToken, systemSesionInfo: response.AuthenticationResult, }; return sessInfo; } catch (err: any) { logger.error(`Refresh token operation failed: ${err.message}`); if (err.name === 'NotAuthorizedException') { throw new UnauthorisedError('Invalid or expired refresh token'); } handleCognitoError(err, 'refreshToken'); throw err; // This line won't be reached due to handleCognitoError throwing } } async inviteUser( email: string, firstName: string, lastName: string, userData: Map<string, any> | undefined, role: string | undefined, env: Environment, cb: InviteUserCallback ): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); const userAttrs = [ { Name: 'email', Value: email, }, { Name: 'email_verified', Value: 'true', }, { Name: 'given_name', Value: firstName, }, { Name: 'family_name', Value: lastName, }, ]; if (userData) { userData.forEach((v: any, k: string) => { userAttrs.push({ Name: k, Value: String(v) }); }); } let userExists = false; try { const getUserCommand = new AdminGetUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, }); await client.send(getUserCommand); userExists = true; logger.debug(`User ${email} already exists, will resend invitation`); } catch (err: any) { if (err.name !== 'UserNotFoundException') { throw err; } logger.debug(`User ${email} does not exist, will create new user`); } const command = new AdminCreateUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, UserAttributes: userAttrs, DesiredDeliveryMediums: ['EMAIL'], TemporaryPassword: generateUrlSafePassword(), ...(userExists ? { MessageAction: 'RESEND' } : {}), }); logger.debug(`Attempting to invite user: ${email}`); const response = await client.send(command); if (response.$metadata.httpStatusCode === 200) { logger.info(`User invitation successful for: ${email}`); const localUser = await ensureUser(email, firstName, lastName, env, 'Invited'); const userId = localUser.lookup('id'); await ensureUserRoles(userId, [role || 'user'], env); const invitationInfo: InvitationInfo = { email: email, firstName: firstName, lastName: lastName, invitationId: response.User?.Username, systemInvitationInfo: response, }; cb(invitationInfo); } else { logger.error( `User invitation failed with HTTP status ${response.$metadata.httpStatusCode}`, { email: email, statusCode: response.$metadata.httpStatusCode, } ); throw new BadRequestError( `User invitation failed with status ${response.$metadata.httpStatusCode}` ); } } catch (err: any) { if (err instanceof BadRequestError) throw err; logger.error(`User invitation error for ${email}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'inviteUser'); } } async resendInvitation(email: string, _env: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); try { const getUserCommand = new AdminGetUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, }); await client.send(getUserCommand); } catch (err: any) { if (err.name === 'UserNotFoundException') { throw new UserNotFoundError(`User ${email} not found. Cannot resend invitation.`); } throw err; } const command = new AdminCreateUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, DesiredDeliveryMediums: ['EMAIL'], MessageAction: 'RESEND', }); logger.debug(`Attempting to resend invitation for user: ${email}`); const response = await client.send(command); if (response.$metadata.httpStatusCode === 200) { logger.info(`Invitation resent successfully for: ${email}`); } else { logger.error( `Failed to resend invitation with HTTP status ${response.$metadata.httpStatusCode}`, { email: email, statusCode: response.$metadata.httpStatusCode, } ); throw new BadRequestError( `Failed to resend invitation with status ${response.$metadata.httpStatusCode}` ); } } catch (err: any) { if (err instanceof BadRequestError || err instanceof UserNotFoundError) { throw err; } logger.error(`Resend invitation error for ${email}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'resendInvitation'); } } async acceptInvitation( email: string, tempPassword: string, newPassword: string, _env: Environment ): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', credentials: fromEnv(), }); const initAuth = new InitiateAuthCommand({ AuthFlow: 'USER_PASSWORD_AUTH', ClientId: this.fetchClientId(), AuthParameters: { USERNAME: email, PASSWORD: tempPassword, }, }); const initResponse = await client.send(initAuth); if (initResponse.ChallengeName === 'NEW_PASSWORD_REQUIRED') { const respond = new RespondToAuthChallengeCommand({ ClientId: this.fetchClientId(), ChallengeName: 'NEW_PASSWORD_REQUIRED', Session: initResponse.Session, ChallengeResponses: { USERNAME: email, NEW_PASSWORD: newPassword, }, }); await client.send(respond); logger.info(`User invitation accepted successfully for: ${email}`); } else { throw new Error(`Unexpected challenge: ${initResponse.ChallengeName}`); } } catch (err: any) { logger.error(`Accept invitation failed for ${email}: ${sanitizeErrorMessage(err.message)}`); handleCognitoError(err, 'acceptInvitation'); } } async callback(code: string, env: Environment, cb: LoginCallback): Promise<void> { try { if (!isNodeEnv) { throw new Error('Callback authentication is only supported in Node.js environment'); } const clientId = this.fetchConfig('ClientId'); const region = process.env.AWS_REGION || 'us-east-1'; const redirectUri = process.env.COGNITO_REDIRECT_URI || 'http://localhost:3000/auth/callback'; const hostedDomain = process.env.COGNITO_HOSTED_DOMAIN; const tokenEndpoint = `https://${hostedDomain}.auth.${region}.amazoncognito.com/oauth2/token`; const tokenRequestBody = new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, code: code, redirect_uri: redirectUri, }); logger.debug(`Exchanging authorization code for tokens via OAuth2 endpoint`); console.log('te', tokenEndpoint, tokenRequestBody.toString()); const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: tokenRequestBody.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} ${errorText}`); } const tokenData = await response.json(); if (!tokenData.access_token || !tokenData.id_token || !tokenData.refresh_token) { throw new Error('Missing required tokens in response'); } const { access_token: AccessToken, id_token: IdToken, refresh_token: RefreshToken, } = tokenData; const idTokenPayload = this.decodeJwtPayload(IdToken); const userEmail = idTokenPayload.email; const firstName = idTokenPayload['given_name'] || idTokenPayload['name'] || ''; const lastName = idTokenPayload['family_name'] || ''; const userGroups = idTokenPayload['cognito:groups']; if (!userEmail) { throw new Error('Email not found in ID attributes'); } let localUser = await findUserByEmail(userEmail, env); if (!localUser) { localUser = await ensureUser(userEmail, firstName, lastName, env); } const userId = localUser.lookup('id'); if (userGroups) { await ensureUserRoles(userId, userGroups, env); } const localSession = await ensureUserSession(userId, IdToken, AccessToken, RefreshToken, env); const sessionInfo: SessionInfo = { sessionId: localSession.lookup('id'), userId: userId, authToken: IdToken, idToken: IdToken, accessToken: AccessToken, refreshToken: RefreshToken, systemSesionInfo: tokenData, }; logger.info(`Auth callback successful for user ${userEmail}`); cb(sessionInfo); } catch (err: any) { console.log(err); logger.error(`Auth callback failed: ${sanitizeErrorMessage(err.message)}`); handleCognitoError(err, 'callback'); } } private decodeJwtPayload(token: string): any { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent( atob(base64) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ); return JSON.parse(jsonPayload); } catch (err) { logger.error(`Failed to decode JWT payload: ${err}`); throw new Error('Invalid JWT token'); } } async deleteUser(email: string, _env: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); const command = new AdminDeleteUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, }); logger.debug(`Attempting to delete user: ${email}`); await client.send(command); logger.info(`User deleted successfully: ${email}`); } catch (err: any) { logger.error(`Failed to delete user ${email}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'deleteUser'); } } async disableUser(email: string, _env: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); const command = new AdminDisableUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, }); logger.debug(`Attempting to disable user: ${email}`); await client.send(command); logger.info(`User disabled successfully: ${email}`); } catch (err: any) { logger.error(`Failed to disable user ${email}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'disableUser'); } } async enableUser(email: string, _env: Environment): Promise<void> { try { const client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION || 'us-west-2', }); const command = new AdminEnableUserCommand({ UserPoolId: this.fetchUserPoolId(), Username: email, }); logger.debug(`Attempting to enable user: ${email}`); await client.send(command); logger.info(`User enabled successfully: ${email}`); } catch (err: any) { logger.error(`Failed to enable user ${email}:`, { errorName: err.name, errorMessage: sanitizeErrorMessage(err.message), }); handleCognitoError(err, 'enableUser'); } } }