import { AppConfigDomainIAM, AppConfigDomainIAMAuthenticationStep, ApplicationError, ConfigProviderService, DataDefaultData, DataEntityService, DomainEntityServiceDefaultData, GenericObject, LoggerService, getNested, setNested } from '@node-c/core'; import ld from 'lodash'; import { IAMAuthenticationManagerAuthenticateOptions, IAMAuthenticationManagerAuthenticateReturnData, IAMAuthenticationManagerExecuteStepData, IAMAuthenticationManagerExecuteStepOptions, IAMAuthenticationManagerExecuteStepResult, IAMAuthenticationManagerUserTokenEnityFields, IAMAuthenticationManagerUserTokenUserIdentifier } from './iam.authenticationManager.definitions'; import { Constants } from '../../common/definitions'; import { IAMAuthenticationCompleteData, IAMAuthenticationCompleteOptions, IAMAuthenticationGetUserDataFromExternalTokenPayloadsData, IAMAuthenticationService, IAMAuthenticationType } from '../authentication'; import { IAMAuthenticationOAuth2CompleteResult, IAMAuthenticationOAuth2Service } from '../authenticationOAuth2'; import { IAMAuthenticationUserLocalCompleteResult, IAMAuthenticationUserLocalService } from '../authenticationUserLocal'; import { IAMTokenManagerService, TokenType } from '../tokenManager'; import { IAMUserWithPermissionsData, IAMUsersService } from '../users'; // TODO: create user (signup); this should include password hashing // TODO: update password (incl. hashing) // TODO: reset password // TODO: periodic checking of external access tokens and their revoking export class IAMAuthenticationManagerService< User extends object = object, Data extends DomainEntityServiceDefaultData> = DomainEntityServiceDefaultData>, DataEntityServiceData extends DataDefaultData> = DataDefaultData> > { constructor( // eslint-disable-next-line no-unused-vars protected authServices: { [IAMAuthenticationType.OAuth2]?: IAMAuthenticationOAuth2Service; [IAMAuthenticationType.UserLocal]?: IAMAuthenticationUserLocalService; } & { [serviceName: string]: IAMAuthenticationService }, // eslint-disable-next-line no-unused-vars protected configProvider: ConfigProviderService, // eslint-disable-next-line no-unused-vars protected logger: LoggerService, // eslint-disable-next-line no-unused-vars protected moduleName: string, // eslint-disable-next-line no-unused-vars protected dataUsersAuthCacheService?: DataEntityService, // eslint-disable-next-line no-unused-vars public domainUsersEntityService?: IAMUsersService< User, DataEntityService, Data, Record, DataDefaultData>> | undefined >, // eslint-disable-next-line no-unused-vars protected tokenManager?: IAMTokenManagerService ) {} // TODO: fix expiry vs TTL // TODO: clear the cache from the previous steps // TODO: make the issuing of local tokens work with purgeOldFromStore = false async authenticate( options: IAMAuthenticationManagerAuthenticateOptions ): Promise> { const { configProvider, logger, moduleName, tokenManager } = this; const moduleConfig = configProvider.config.domain[moduleName] as AppConfigDomainIAM; const { accessTokenExpiryTimeInMinutes, defaultUserIdentifierField, refreshTokenExpiryTimeInHours } = moduleConfig; const { auth: { type: authType }, rememberUser } = options; logger.info( `[Domain.${moduleName}.AuthenticationManager][${authType}]: Login attempt started${options.step ? ` for step ${options.step}` : ''}.` ); // 1. Make sure the authentication service actually exists - local, oauth2, etc. const authService = this.authServices[authType] as IAMAuthenticationService; if (!authService) { logger.info(`[Domain.${moduleName}.AuthenticationManager][${authType}]: No authService ${authType} found.`); throw new ApplicationError('Authentication failed.'); } // 2. Get the user-specific configuration from the authService. const authServiceBehaviorConfig = authService.getUserAuthenticationConfig(); let externalAccessToken: string | undefined; let externalRefreshToken: string | undefined; let issueTokens = false; let step: AppConfigDomainIAMAuthenticationStep; let userFilterField: string | undefined; let userFilterValue: unknown | undefined; // 3. Prepare the step behavior based on the configuration. // 3.1. Complete step if (options.step === AppConfigDomainIAMAuthenticationStep.Complete) { issueTokens = true; step = AppConfigDomainIAMAuthenticationStep.Complete; } // 3.2. Initiate step - assumed implicitly. else { step = AppConfigDomainIAMAuthenticationStep.Initiate; } let stepConfig = authServiceBehaviorConfig[step]; // 3. Run the authentication method itself. // eslint-disable-next-line prefer-const let { stepResult, user, ...otherStepData } = await this.executeStep(options, { authService, name: step, stepConfig }); // 4. Run the final step, if this is the first step no mfa has been used. if (step === AppConfigDomainIAMAuthenticationStep.Initiate && !stepResult.mfaUsed) { issueTokens = true; // check whether skipping the complete step if mfaUsed is allowed and run the complete step if it isn't if (!('skipCompleteStepAllowedOnNoMFA' in stepConfig && stepConfig.skipCompleteStepAllowedOnNoMFA)) { step = AppConfigDomainIAMAuthenticationStep.Complete; stepConfig = authServiceBehaviorConfig[step]; const finalStepData = await this.executeStep(options, { authService, name: step, stepConfig: ld.omit(stepConfig, 'cache') }); stepResult = ld.merge(ld.omit(stepResult, ['mfaUsed', 'mfaValid', 'valid']), finalStepData.stepResult); user = user ?? finalStepData.user; userFilterField = finalStepData.userFilterField; userFilterValue = finalStepData.userFilterValue; } else { if ('userFilterField' in stepResult) { userFilterField = stepResult.userFilterField as string; } if ('userFilterValue' in stepResult) { userFilterValue = stepResult.userFilterValue as string; } } } // 5. Process the external access, refresh and, optionally, id tokens that are returned by the step execution. const actualStepResult = stepResult as | IAMAuthenticationOAuth2CompleteResult | IAMAuthenticationUserLocalCompleteResult; if (!userFilterField && otherStepData.userFilterField) { userFilterField = otherStepData.userFilterField; } if (!userFilterValue && otherStepData.userFilterValue) { userFilterValue = otherStepData.userFilterValue; } if ('useReturnedTokens' in stepConfig && stepConfig.useReturnedTokens && stepConfig.authReturnsTokens) { // Make sure we have an accessToken in the response and set the access and refresh tokens in variables for later use. if (!actualStepResult.accessToken) { logger.info( `[Domain.${moduleName}.AuthenticationManager][${authType}]: Login attempt failed for ${userFilterField} ${userFilterValue} - no accessToken returned from the authService and useReturnedTokens is set to true.` ); throw new ApplicationError('Authentication failed.'); } externalAccessToken = actualStepResult.accessToken; if (actualStepResult.refreshToken) { externalRefreshToken = actualStepResult.refreshToken; } } // 6. Token management. In this case, we will definitely have the user, or will be force to create it. if (issueTokens) { if (!tokenManager) { throw new ApplicationError(`[${moduleName}][AuthenticationManager] tokenManager not configured.`); } if (!user) { logger.info( `[Domain.${moduleName}.AuthenticationManager][${authType}]: Login attempt failed at step ${step} - user is required when issueTokens is set to true.` ); throw new ApplicationError('Authentication failed.'); } const useExternalTokenAsLocal = 'useReturnedTokensAsLocal' in stepConfig && stepConfig.useReturnedTokensAsLocal; const userIdentifierValue = user[defaultUserIdentifierField as keyof User]; let refreshToken: string | undefined; let refreshTokenExpiresIn: number | undefined; let refreshTokenTTL: number | undefined; // 6.1. Create a local refresh token and save it. The payload contains the external refresh token, if it exists. if (externalRefreshToken || !externalAccessToken) { let externalTokenData: GenericObject = {}; if ( externalRefreshToken && 'refreshTokenExpiresIn' in actualStepResult && actualStepResult.refreshTokenExpiresIn ) { externalTokenData = { externalToken: externalRefreshToken, externalTokenAuthService: authType as IAMAuthenticationType }; refreshTokenExpiresIn = actualStepResult.refreshTokenExpiresIn; } else if (!rememberUser) { refreshTokenExpiresIn = (refreshTokenExpiryTimeInHours ? refreshTokenExpiryTimeInHours : Constants.DEFAULT_REFRESH_TOKEN_EXPIRY_TIME_IN_HOURS) * 60; } if (refreshTokenExpiresIn) { refreshTokenTTL = refreshTokenExpiresIn * (moduleConfig.refreshTokenExpiryStorageTTLMultiplier || Constants.DEFAULT_REFRESH_TOKEN_STORAGE_TTL_MULTIPLIER); } const { result: { token: localRefreshToken } } = await tokenManager.create( { type: TokenType.Refresh, [IAMAuthenticationManagerUserTokenUserIdentifier.FieldName]: userIdentifierValue, ...externalTokenData }, { expiresInMinutes: refreshTokenExpiresIn, identifierDataField: IAMAuthenticationManagerUserTokenUserIdentifier.FieldName, persist: true, purgeOldFromData: true, tokenContentOnlyFields: ['externalToken'], ttl: refreshTokenTTL, useExternalTokenAsLocal } ); refreshToken = localRefreshToken; } // 6.2. Create a local access token and save it. The payload contains the external access token, if it exists. const accessTokenExpiresIn = (externalAccessToken && 'accessTokenExpiresIn' in actualStepResult && actualStepResult.accessTokenExpiresIn) || accessTokenExpiryTimeInMinutes || Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_TIME_IN_HOURS; const accessTokenTTL = refreshTokenExpiresIn || accessTokenExpiresIn * (moduleConfig.accessTokenExpiryStorageTTLMultiplier || Constants.DEFAULT_ACCESS_TOKEN_STORAGE_TTL_MULTIPLIER); const { result: { token: accessToken } } = await tokenManager.create( { refreshToken, type: TokenType.Access, [IAMAuthenticationManagerUserTokenUserIdentifier.FieldName]: userIdentifierValue, ...(externalAccessToken ? { externalToken: externalAccessToken, externalTokenAuthService: authType as IAMAuthenticationType } : {}) }, { expiresInMinutes: accessTokenExpiresIn, identifierDataField: IAMAuthenticationManagerUserTokenUserIdentifier.FieldName, persist: true, purgeOldFromData: true, tokenContentOnlyFields: ['externalToken', 'refreshToken'], ttl: accessTokenTTL, useExternalTokenAsLocal } ); // 6.3. Create an idToken. The payload contains the user with permissions data const { result: { token: idToken } } = await tokenManager.create( { accessToken, type: TokenType.Id, user, [IAMAuthenticationManagerUserTokenUserIdentifier.FieldName]: userIdentifierValue }, { expiresInMinutes: accessTokenExpiresIn, identifierDataField: IAMAuthenticationManagerUserTokenUserIdentifier.FieldName, persist: true, purgeOldFromData: true, tokenContentOnlyFields: ['accessToken', 'user'], ttl: accessTokenTTL } ); logger.info( `[Domain.${moduleName}.AuthenticationManager][${authType}]: Login attempt successful for ${userFilterField} ${userFilterValue}.` ); return { accessToken, idToken, refreshToken, user }; } const returnData: IAMAuthenticationManagerAuthenticateReturnData = { nextStepsRequired: true }; if (stepConfig.stepResultPublicFields?.length) { stepConfig.stepResultPublicFields.forEach(fieldName => { setNested( returnData, fieldName, getNested(stepResult, fieldName, { removeNestedFieldEscapeSign: true }).unifiedValue, { removeNestedFieldEscapeSign: true } ); }); } return returnData; } private async executeStep( data: IAMAuthenticationManagerExecuteStepData, options: IAMAuthenticationManagerExecuteStepOptions ): Promise> { const { configProvider, dataUsersAuthCacheService, domainUsersEntityService, logger, moduleName } = this; const { defaultUserIdentifierField } = configProvider.config.domain[moduleName] as AppConfigDomainIAM; const { auth: { type: authType, ...authData }, filters: userFilters, mainFilterField } = data; const { authService, stepConfig, name: stepName } = options; const { cache: cacheSettings, findUser, findUserBeforeAuth, validWithoutUser } = stepConfig; const hasFilters = userFilters && Object.keys(userFilters).length; const logPrefix = `[Domain.${moduleName}.AuthenticationManager][executeStep][${authType}][${stepName}]`; const stepInputData: { data: unknown; options?: unknown } = { data: ld.cloneDeep(authData) }; let runFindUserInExternalTokenPayloads = false; let user: IAMUserWithPermissionsData | null = null; let userFilterField: string | undefined; let userFilterValue: unknown | undefined; // 1. Find the user based on the provided filters, if enabled. if (findUser && findUserBeforeAuth) { if (!hasFilters) { logger.info(`${logPrefix}[Part 1]: No filters provided for findUserBeforeToken=true.`); throw new ApplicationError('Authentication failed.'); } userFilterField = mainFilterField; userFilterValue = userFilters[userFilterField]; user = await this.getUserForStepExecution({ filters: userFilters, mainFilterField: userFilterField }); if (!user) { logger.info( `${logPrefix}[Part 1]: Login attempt failed for ${userFilterField} ${userFilterValue} - user not found.` ); throw new ApplicationError('Authentication failed.'); } } if (user) { stepInputData.options = { context: user, contextIdentifierField: defaultUserIdentifierField }; } else if (userFilters) { stepInputData.options = { context: userFilters, contextIdentifierField: mainFilterField }; } // 2. Restore the cache, if configured. if (cacheSettings && 'use' in cacheSettings && cacheSettings.use) { if (!dataUsersAuthCacheService) { logger.info(`${logPrefix}[Part 2]: dataUsersAuthCacheService not configured.`); throw new ApplicationError('Authentication failed.'); } const cacheInput: { data: unknown; options: unknown } = { data: stepInputData.data, options: stepInputData.options }; const cacheResult = await dataUsersAuthCacheService.findOne({ filters: { [cacheSettings.settings.cacheFieldName]: getNested(cacheInput, cacheSettings.settings.inputFieldName) .unifiedValue } }); if (cacheResult) { for (const inputName in cacheSettings.use) { const { overwrite, use } = cacheSettings.use[inputName as keyof typeof cacheSettings.use]!; if (!use) { continue; } const valueFromCache = getNested(cacheResult, inputName, { removeNestedFieldEscapeSign: true }).unifiedValue || {}; const inputNameKey = inputName as keyof typeof stepInputData; if (overwrite) { stepInputData[inputNameKey] = ld.merge(stepInputData[inputNameKey], valueFromCache); continue; } stepInputData[inputNameKey] = ld.merge(valueFromCache, stepInputData[inputNameKey]); } } } // 3. Run the step method itself. let stepResult = await authService[stepName as 'complete' | 'initiate']( stepInputData.data as IAMAuthenticationCompleteData, stepInputData.options as IAMAuthenticationCompleteOptions ); // 4. Process the step result if ( (!stepResult.valid && !(stepResult as unknown as { nextStepsRequired: boolean }).nextStepsRequired) || (stepResult.mfaUsed && !stepResult.mfaValid) ) { logger.info(`${logPrefix}[Part 4]: Bad step result:`, stepResult); throw new ApplicationError('Authentication failed.'); } // 5. If the step returns tokens and decoding is enabled, decode the reutrned tokens for payloads. if ('decodeReturnedTokens' in stepConfig && stepConfig.decodeReturnedTokens) { const tokensForDecoding: Record = {}; const tokenKeys = ['accessToken', 'idToken', 'refreshToken']; tokenKeys.forEach(tokenKey => { const resultForKey = stepResult[tokenKey as keyof typeof stepResult] as unknown as string; if (!resultForKey) { return; } tokensForDecoding[tokenKey] = resultForKey; }); const externalTokenPayloads = await authService.getPayloadsFromExternalTokens(tokensForDecoding); stepResult = { ...stepResult, ...externalTokenPayloads }; } // 6. Find the user based on either the provided filters, or on the stepResult data, if enabled. if (findUser && !findUserBeforeAuth) { if ('findUserInAuthResultBy' in stepConfig && stepConfig.findUserInAuthResultBy) { const { userFieldName, resultFieldName } = stepConfig.findUserInAuthResultBy; const payloadFilterValue = getNested(stepResult, resultFieldName, { removeNestedFieldEscapeSign: true }).unifiedValue; userFilterField = userFieldName; if (typeof payloadFilterValue !== 'undefined') { userFilterValue = payloadFilterValue; } if (typeof userFilterValue !== 'undefined') { user = await this.getUserForStepExecution({ filters: { [userFieldName]: userFilterValue }, mainFilterField: userFieldName }); } } else if ('findUserInExternalTokenPayloads' in stepConfig && stepConfig.findUserInExternalTokenPayloads) { runFindUserInExternalTokenPayloads = true; } else if (hasFilters) { userFilterField = mainFilterField; userFilterValue = userFilters[userFilterField]; user = await this.getUserForStepExecution({ filters: userFilters, mainFilterField: userFilterField }); } } // 7. Create a user using the data from the tokens returned by the step execution, if enabled and there is no user found. const createUser = 'createUser' in stepConfig && stepConfig.createUser; if (!user && (createUser || runFindUserInExternalTokenPayloads)) { const userData = await authService.getUserDataFromExternalTokenPayloads( stepResult as IAMAuthenticationGetUserDataFromExternalTokenPayloadsData ); if (createUser && userData) { if (!domainUsersEntityService) { logger.info(`${logPrefix}[Part 7]: domainUsersEntityService not configured.`); throw new ApplicationError('Authentication failed.'); } const { result: createdUser } = await domainUsersEntityService.create(userData as unknown as Data['Create']); user = await domainUsersEntityService.getUserWithPermissionsData( { filters: { [defaultUserIdentifierField]: createdUser[defaultUserIdentifierField as keyof typeof createdUser] } }, { keepPassword: false } ); } else if (runFindUserInExternalTokenPayloads) { user = userData as unknown as IAMUserWithPermissionsData; } } if (validWithoutUser !== true && !user) { logger.info( `${logPrefix}[Part 7]: Login attempt failed ${userFilterField && userFilterValue ? `for ${userFilterField} ${userFilterValue} ` : ''}- user not found.` ); throw new ApplicationError('Authentication failed.'); } if (user && 'password' in user) { delete user.password; } // 8. Populate the cache, if configured if (cacheSettings && 'populate' in cacheSettings && cacheSettings.populate) { if (!dataUsersAuthCacheService) { logger.info(`${logPrefix}[Part 7]: dataUsersAuthCacheService not configured.`); throw new ApplicationError('Authentication failed.'); } const cacheInput: GenericObject = { data: stepInputData.data, options: stepInputData.options, result: stepResult }; const cacheData: GenericObject = {}; for (const inputName in cacheSettings.populate) { const inputSettings = cacheSettings.populate[inputName as keyof typeof cacheSettings.populate]; if (inputSettings instanceof Array) { const innerInputItem: GenericObject = {}; inputSettings.forEach(inputItemSettings => { const { cacheFieldName, inputFieldName } = inputItemSettings; setNested( innerInputItem, cacheFieldName, getNested(cacheInput, inputFieldName, { removeNestedFieldEscapeSign: true }).unifiedValue ); }); cacheData[inputName] = innerInputItem; continue; } cacheData[inputName] = cacheInput[inputName]; } await dataUsersAuthCacheService.create({ ...cacheData, [cacheSettings.settings.cacheFieldName]: getNested(cacheInput, cacheSettings.settings.inputFieldName) .unifiedValue }); } return { stepResult, user, userFilterField, userFilterValue }; } protected async getUserForStepExecution(options: { filters: GenericObject; mainFilterField: string; }): Promise | null> { const { configProvider, domainUsersEntityService, moduleName } = this; if (!domainUsersEntityService) { throw new ApplicationError(`[${moduleName}][AuthenticationManager] domainUsersEntityService not configured.`); } const { defaultUserIdentifierField } = configProvider.config.domain[moduleName] as AppConfigDomainIAM; const { mainFilterField } = options; let filters: GenericObject = options.filters; let user: IAMUserWithPermissionsData | null = null; if (mainFilterField !== defaultUserIdentifierField) { // Allow search by an extended range of filters, directly in the database. // This is needed because getUserWithPermissionsData will usually query the cache, where a // prmary key filter is mandatory. const mainFilterFieldResult = await domainUsersEntityService.findOne({ filters }); if (!mainFilterFieldResult.result) { return null; } filters = { [defaultUserIdentifierField]: mainFilterFieldResult.result[defaultUserIdentifierField as keyof typeof mainFilterFieldResult.result] }; } else { filters = options.filters; } user = await domainUsersEntityService.getUserWithPermissionsData({ filters }, { keepPassword: true }); return user; } }