Skip to content

Changes done to handle login with otp and auto registration flow based on configuration#888

Open
Vinod-V3 wants to merge 4 commits into
ELEVATE-Project:developfrom
Vinod-V3:login-enhancement
Open

Changes done to handle login with otp and auto registration flow based on configuration#888
Vinod-V3 wants to merge 4 commits into
ELEVATE-Project:developfrom
Vinod-V3:login-enhancement

Conversation

@Vinod-V3

@Vinod-V3 Vinod-V3 commented Jun 22, 2026

Copy link
Copy Markdown

Summary by CodeRabbit

Release Notes

  • New Features

    • Added tenant-level authentication configuration (allowed auth modes and auto-registration).
    • Extended authentication to support OTP and username-based flows in addition to email/phone.
    • Added purpose-based OTP handling for signup vs login, including conditional auto-registration.
  • Data & Model Updates

    • Tenants now store authentication configuration by default.
    • User name/password fields are more permissive to support the new flows.
  • Validation & API Changes

    • Updated request validation for optional fields and expanded identifier support.
    • Tenant reads and responses now include configuration.
  • Localization

    • Added/updated authentication-related messages in English and Hindi.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6fc6639f-bb16-4c21-ba08-b40c2eb7160f

📥 Commits

Reviewing files that changed from the base of the PR and between c2b4f27 and 8ff2044.

📒 Files selected for processing (9)
  • src/constants/common.js
  • src/database/migrations/20260621090000-make-user-name-and-password-nullable.js
  • src/database/migrations/20260623124000-add-name-entity-type.js
  • src/envVariables.js
  • src/generics/utils.js
  • src/services/account.js
  • src/services/tenant.js
  • src/utils/usernameGenerator.js
  • src/validators/v1/user.js
✅ Files skipped from review due to trivial changes (1)
  • src/database/migrations/20260623124000-add-name-entity-type.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/constants/common.js
  • src/services/tenant.js
  • src/services/account.js

Walkthrough

Adds tenant auth configuration defaults and persistence, exposes tenant configuration through tenant APIs, updates account validation and OTP handling for email, phone, and username flows, and adds a migration for entity_types name records.

Changes

Tenant auth configuration and account flow refactor

Layer / File(s) Summary
Auth constants and tenant schema
src/constants/common.js, src/envVariables.js, src/database/migrations/20260618090000-add-configuration-to-tenants.js, src/database/models/Tenant.js
Defines tenant auth defaults and constants, adds env vars for default auth modes and auto-register, and persists tenant configuration in the tenant schema and migration.
Tenant configuration propagation and validation
src/services/tenant.js, src/dtos/tenantDTO.js, src/validators/v1/tenant.js
Tenant create, read, event payloads, and DTO output include configuration, and tenant update validation normalizes and checks configuration shape.
User identity and nullable fields
src/database/models/users.js, src/database/migrations/20260621090000-make-user-name-and-password-nullable.js, src/generics/utils.js, src/utils/usernameGenerator.js, src/validators/v1/account.js, src/locales/en.json, src/locales/hi.json
User name/password fields become nullable, identifier typing and username generation are updated, and account validators plus locale messages add OTP and username support.
Account create and registration OTP helpers
src/services/account.js
Adds purpose-qualified OTP Redis helpers, applies tenant auth mode checks in create, validates registration OTP against purpose-scoped Redis keys, and clears used OTP keys after registration.
Account login enforcement
src/services/account.js
Login classifies identifiers, enforces allowed auth modes, resolves OTP purpose from user presence, supports OTP-based auto-registration, and deletes the used login OTP key on success.
Username-based OTP issuance
src/services/account.js
registrationOtp accepts username, branches between username and contact flows, reuses or generates purpose-scoped OTP values, and sends the matching notification payload.

Entity type backfill migration

Layer / File(s) Summary
Name entity type migration
src/database/migrations/20260623124000-add-name-entity-type.js
Adds a transactional migration that inserts and removes entity_types rows for value: 'name'.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AccountService
  participant TenantDB
  participant UserDB
  participant Redis

  Client->>AccountService: registrationOtp / login / create
  AccountService->>TenantDB: read tenant.configuration.allowed_auth_mode
  TenantDB-->>AccountService: configuration
  AccountService->>UserDB: lookup user by email / phone / username
  UserDB-->>AccountService: user or null
  AccountService->>Redis: get / set purpose-qualified OTP
  Redis-->>AccountService: OTP payload
  AccountService->>AccountService: enforce auth mode and identifier rules
  AccountService->>Redis: delete used OTP key
  AccountService-->>Client: response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I hopped through tenants, keys, and names,
And tweaked the OTP warren’s games.
With username trails and auth modes neat,
The rabbit’s code now stays on its feet.
🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: OTP login plus configuration-based auto-registration behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/validators/v1/tenant.js (1)

56-69: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update path still allows unvalidated configuration payloads.

In the req.params.id branch (Line 58 onward), configuration is normalized but not validated with isValidTenantConfiguration (same for theming/meta). Invalid objects can pass through update and be persisted.

✅ Suggested fix (mirror create-path checks in update branch)
 if (req.params.id) {
   req.body = filterRequestBody(req.body, tenant.update)
   normalizeTenantConfiguration(req)
   req.checkParams('id')
     .trim()
     .notEmpty()
     .withMessage('code param is empty')
     .matches(/^[a-zA-Z0-9_]+$/)
     .withMessage('Code must contain only letters, numbers, and underscores')
+
+  req.checkBody('theming')
+    .optional({ checkFalsy: true })
+    .custom(isValidObject)
+    .withMessage('Theming must be a valid object or a JSON string representing an object')
+
+  req.checkBody('configuration')
+    .optional({ checkFalsy: true })
+    .custom(isValidTenantConfiguration)
+    .withMessage('Configuration must include allowed_auth_mode, and auto_register')
+
+  req.checkBody('meta')
+    .optional({ checkFalsy: true })
+    .custom(isValidObject)
+    .withMessage('Meta must be a valid object or a JSON string representing an object')
 } else {

As per path instructions, src/validators/**: "Validate all incoming data thoroughly. Check for missing or incomplete validation rules."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/validators/v1/tenant.js` around lines 56 - 69, The update method is
missing validation checks for configuration, theming, and meta fields in the
req.params.id branch after normalizeTenantConfiguration is called. Add the same
validation checks that are present in the create branch (mirror the
isValidTenantConfiguration and related validations) to the update branch,
ensuring that configuration, theming, and meta objects are validated using
req.checkBody before they can be persisted. These validation checks should be
added after the normalizeTenantConfiguration call but before the update
operation completes.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/database/migrations/20260621090000-make-user-name-and-password-nullable.js`:
- Around line 21-35: The down migration function attempts to restore allowNull:
false constraints on the name column in users table, password column in users
table, and password column in users_credentials table, but does not handle null
values that may have been created while the up migration was active. Before
calling changeColumn to set allowNull: false for each of these columns, add
update statements in the down function to replace any existing null values with
a safe default value such as an empty string. This ensures the rollback can
execute successfully without constraint violations.

In `@src/services/account.js`:
- Around line 1440-1444: The current check using includes('OK') on
redisSetResults only verifies that at least one Redis write succeeded, but in
multi-identifier flows you need to ensure ALL OTP writes completed successfully.
Replace the includes('OK') check in the redisSetResults validation with a check
that verifies every element in the redisSetResults array is 'OK', such as using
the every() method, to guarantee all OTP values were stored in Redis before
returning success.
- Around line 1335-1456: In the code section that sends SMS notification,
replace the condition that checks `bodyData.phone_code` with a check against the
`phoneCode` variable instead. The `phoneCode` variable is properly hydrated from
the user record in the username branch of the conditional, so using it ensures
SMS notifications are sent when a user has a phone number on file, even if the
request body doesn't contain phone_code. This fix applies to the SMS delivery
gate logic that mirrors the email notification check shown in the diff.

In `@src/validators/v1/account.js`:
- Around line 146-159: Both the password and otp fields in the account validator
are independently optional, which allows requests with neither credential to
pass validation. Add a cross-field validation check using a custom validator
that runs after the individual field validations to ensure at least one of
password or otp is provided in the request body. This should be done at the
request level to reject payloads that contain neither credential during
validation time rather than failing later in the service layer.

---

Outside diff comments:
In `@src/validators/v1/tenant.js`:
- Around line 56-69: The update method is missing validation checks for
configuration, theming, and meta fields in the req.params.id branch after
normalizeTenantConfiguration is called. Add the same validation checks that are
present in the create branch (mirror the isValidTenantConfiguration and related
validations) to the update branch, ensuring that configuration, theming, and
meta objects are validated using req.checkBody before they can be persisted.
These validation checks should be added after the normalizeTenantConfiguration
call but before the update operation completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7d446886-5465-44cd-bfb0-18d496de229d

📥 Commits

Reviewing files that changed from the base of the PR and between c1ba472 and c2b4f27.

📒 Files selected for processing (12)
  • src/constants/common.js
  • src/database/migrations/20260618090000-add-configuration-to-tenants.js
  • src/database/migrations/20260621090000-make-user-name-and-password-nullable.js
  • src/database/models/Tenant.js
  • src/database/models/users.js
  • src/dtos/tenantDTO.js
  • src/locales/en.json
  • src/locales/hi.json
  • src/services/account.js
  • src/services/tenant.js
  • src/validators/v1/account.js
  • src/validators/v1/tenant.js

Comment thread src/services/account.js
Comment on lines +1335 to +1456
let phoneCode = bodyData.phone_code
let notificationName = bodyData.name
let redisIdentifiers = []
let purpose = OTP_PURPOSES.SIGNUP

if (criteria.length === 0) {
return // Skip if no criteria
}
if (username) {
const user = await userQueries.findOne(
{
username,
tenant_code: tenantDetail.code,
status: common.ACTIVE_STATUS,
},
{
attributes: ['email', 'phone', 'phone_code', 'name', 'username'],
}
)

// Check if user already exists with email or phone or username
let user = await userQueries.findOne(
{
[Op.or]: criteria,
password: { [Op.ne]: null },
tenant_code: tenantDetail.code,
},
{
attributes: ['id'],
if (!user) {
return responses.failureResponse({
message: 'ACCOUNT_NOT_FOUND',
statusCode: httpStatusCode.not_found,
responseCode: 'CLIENT_ERROR',
})
}
)

// Return error if user already exists
if (user) {
return responses.failureResponse({
message: 'USER_ALREADY_EXISTS',
statusCode: httpStatusCode.not_acceptable,
responseCode: 'CLIENT_ERROR',
})
}

// Generate or retrieve OTP
let emailUserData = encryptedEmailId ? await utilsHelper.redisGet(encryptedEmailId) : null
let phoneUserData =
encryptedPhoneNumber && bodyData.phone_code
? await utilsHelper.redisGet(bodyData.phone_code + encryptedPhoneNumber)
: null
purpose = OTP_PURPOSES.LOGIN

// Use existing OTP if already in progress, otherwise generate a new one
const userData = emailUserData || phoneUserData
// Usage: [generateSecureOTP(), true]
if (!allowedAuthMode.includes(AUTH_MODES.OTP)) {
return failureResponse('AUTH_MODE_NOT_ALLOWED')
}

const [otp, isNew] =
userData && userData.action === 'signup' ? [userData.otp, false] : [utils.generateSecureOTP(), true]
encryptedEmailId = user.email || null
encryptedPhoneNumber = user.phone || null
phoneCode = user.phone_code || null
plaintextEmailId = encryptedEmailId ? emailEncryption.decrypt(encryptedEmailId) : null
plaintextPhoneNumber = encryptedPhoneNumber ? emailEncryption.decrypt(encryptedPhoneNumber) : null
notificationName = bodyData.name || user.name || 'User'

// Store OTP data in redis if it's new
if (isNew) {
const redisData = {
verify: encryptedEmailId || encryptedPhoneNumber,
action: 'signup',
otp,
if (!plaintextEmailId && !plaintextPhoneNumber) {
return failureResponse('USER_CONTACT_NOT_AVAILABLE')
}

let redisSetResults = []

// Store OTP for email if provided
if (encryptedEmailId) {
const emailResult = await utilsHelper.redisSet(encryptedEmailId, redisData, common.otpExpirationTime)
redisSetResults.push(emailResult)
redisIdentifiers = [username]
if (encryptedEmailId) redisIdentifiers.push(encryptedEmailId)
if (encryptedPhoneNumber && phoneCode) redisIdentifiers.push(phoneCode + encryptedPhoneNumber)
} else {
if (bodyData.email) {
plaintextEmailId = bodyData.email.toLowerCase()
encryptedEmailId = emailEncryption.encrypt(plaintextEmailId)
}

// Store OTP for phone if provided
if (encryptedPhoneNumber && bodyData.phone_code) {
const phoneResult = await utilsHelper.redisSet(
bodyData.phone_code + encryptedPhoneNumber,
redisData,
common.otpExpirationTime
)
redisSetResults.push(phoneResult)
if (bodyData.phone && bodyData.phone_code) {
plaintextPhoneNumber = bodyData.phone
encryptedPhoneNumber = emailEncryption.encrypt(plaintextPhoneNumber)
}

// Check if storing in Redis was successful
if (!redisSetResults.includes('OK')) {
const criteria = []
if (encryptedEmailId) criteria.push({ email: encryptedEmailId })
if (encryptedPhoneNumber) criteria.push({ phone: encryptedPhoneNumber })

const existingUser =
criteria.length > 0
? await userQueries.findOne(
{
[Op.or]: criteria,
tenant_code: tenantDetail.code,
},
{ attributes: ['id'] }
)
: null

const userExists = Boolean(existingUser)
purpose = resolveOtpPurpose({
username: null,
registrationCode: bodyData.registration_code,
userExists,
})

if (bodyData.registration_code && userExists) {
return responses.failureResponse({
message: 'UNABLE_TO_SEND_OTP',
statusCode: httpStatusCode.internal_server_error,
responseCode: 'SERVER_ERROR',
message: 'USER_ALREADY_EXISTS',
statusCode: httpStatusCode.not_acceptable,
responseCode: 'CLIENT_ERROR',
})
}

if (purpose === OTP_PURPOSES.LOGIN && !allowedAuthMode.includes(AUTH_MODES.OTP)) {
return failureResponse('AUTH_MODE_NOT_ALLOWED')
}

if (encryptedEmailId) redisIdentifiers.push(encryptedEmailId)
if (encryptedPhoneNumber && phoneCode) redisIdentifiers.push(phoneCode + encryptedPhoneNumber)
}

const existingOtpData = await getExistingOtpForPurpose(purpose, redisIdentifiers)

const otp = existingOtpData ? existingOtpData.otp : utils.generateSecureOTP()
const isNew = !existingOtpData

const redisData = {
verify: username || encryptedEmailId || encryptedPhoneNumber,
action: purpose,
otp,
}

const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)

// Check if storing in Redis was successful
if (!redisSetResults.includes('OK')) {
return responses.failureResponse({
message: 'UNABLE_TO_SEND_OTP',
statusCode: httpStatusCode.internal_server_error,
responseCode: 'SERVER_ERROR',
})
}

// Send email notification with OTP if email is provided
if (plaintextEmailId) {
notificationUtils.sendEmailNotification({
emailId: plaintextEmailId,
templateCode: process.env.REGISTRATION_OTP_EMAIL_TEMPLATE_CODE,
variables: { name: bodyData.name || plaintextEmailId, otp },
variables: { name: notificationName || plaintextEmailId, otp },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use phoneCode (not bodyData.phone_code) when deciding SMS delivery in username OTP flow.

In the username branch you hydrate phoneCode from the user record, but Line 1463 still gates SMS on bodyData.phone_code. Username-only requests usually don’t include that field, so OTP can be generated/stored without being delivered via SMS.

Suggested patch
-		if (plaintextPhoneNumber && bodyData.phone_code) {
+		if (plaintextPhoneNumber && phoneCode) {
 			notificationUtils.sendSMSNotification({
 				phoneNumber: plaintextPhoneNumber,
 				templateCode: process.env.REGISTRATION_OTP_EMAIL_TEMPLATE_CODE,
 				variables: { app_name: tenantDetail.name, otp },
 				tenantCode: tenantDetail.code,
 				organization_code: process.env.DEFAULT_ORGANISATION_CODE || null,
 			})
 		}

As per path instructions, src/services/**: This is core business logic. Please check for correctness, efficiency, and potential edge cases.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 1439-1439: Avoid using the initial state variable in setState
Context: setOtpForPurpose(purpose, redisIdentifiers, redisData)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/account.js` around lines 1335 - 1456, In the code section that
sends SMS notification, replace the condition that checks `bodyData.phone_code`
with a check against the `phoneCode` variable instead. The `phoneCode` variable
is properly hydrated from the user record in the username branch of the
conditional, so using it ensures SMS notifications are sent when a user has a
phone number on file, even if the request body doesn't contain phone_code. This
fix applies to the SMS delivery gate logic that mirrors the email notification
check shown in the diff.

Source: Path instructions

Comment thread src/services/account.js
Comment on lines +1440 to +1444
const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)

// Check if storing in Redis was successful
if (!redisSetResults.includes('OK')) {
return responses.failureResponse({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Require all OTP Redis writes to succeed before returning success.

includes('OK') passes even when only one key write succeeds. For multi-identifier flows, that can acknowledge OTP delivery while one login path has no stored OTP.

Suggested patch
 		const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)

 		// Check if storing in Redis was successful
-		if (!redisSetResults.includes('OK')) {
+		const allRedisWritesSucceeded =
+			redisIdentifiers.length > 0 &&
+			redisSetResults.length === redisIdentifiers.length &&
+			redisSetResults.every((status) => status === 'OK')
+		if (!allRedisWritesSucceeded) {
 			return responses.failureResponse({
 				message: 'UNABLE_TO_SEND_OTP',
 				statusCode: httpStatusCode.internal_server_error,
 				responseCode: 'SERVER_ERROR',
 			})
 		}

As per path instructions, src/services/**: This is core business logic. Please check for correctness, efficiency, and potential edge cases.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)
// Check if storing in Redis was successful
if (!redisSetResults.includes('OK')) {
return responses.failureResponse({
const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)
// Check if storing in Redis was successful
const allRedisWritesSucceeded =
redisIdentifiers.length > 0 &&
redisSetResults.length === redisIdentifiers.length &&
redisSetResults.every((status) => status === 'OK')
if (!allRedisWritesSucceeded) {
return responses.failureResponse({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/account.js` around lines 1440 - 1444, The current check using
includes('OK') on redisSetResults only verifies that at least one Redis write
succeeded, but in multi-identifier flows you need to ensure ALL OTP writes
completed successfully. Replace the includes('OK') check in the redisSetResults
validation with a check that verifies every element in the redisSetResults array
is 'OK', such as using the every() method, to guarantee all OTP values were
stored in Redis before returning success.

Source: Path instructions

Comment on lines +146 to +159
req.checkBody('password')
.optional({ checkFalsy: true })
.trim()
.matches(process.env.PASSWORD_POLICY_REGEX)
.withMessage(process.env.PASSWORD_POLICY_MESSAGE)
.custom((value) => !/\s/.test(value))
.withMessage('Password cannot contain spaces')

req.checkBody('otp')
.optional({ checkFalsy: true })
.matches(/^[0-9]+$/)
.withMessage('otp should be number')
.isLength({ min: 6, max: 6 })
.withMessage('Otp is invalid')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a cross-field check so login rejects requests with neither password nor OTP at validation time.

Right now both fields are optional, so payloads with no credential pass validation and fail later in service. Add a validator-level guard for completeness.

Suggested patch
 		req.checkBody('otp')
 			.optional({ checkFalsy: true })
 			.matches(/^[0-9]+$/)
 			.withMessage('otp should be number')
 			.isLength({ min: 6, max: 6 })
 			.withMessage('Otp is invalid')
+
+		req.checkBody(['password', 'otp']).custom(() => {
+			if (!req.body.password && !req.body.otp) {
+				throw new Error('Password or OTP is required')
+			}
+			return true
+		})

As per path instructions, src/validators/**: Validate all incoming data thoroughly. Check for missing or incomplete validation rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/validators/v1/account.js` around lines 146 - 159, Both the password and
otp fields in the account validator are independently optional, which allows
requests with neither credential to pass validation. Add a cross-field
validation check using a custom validator that runs after the individual field
validations to ensure at least one of password or otp is provided in the request
body. This should be done at the request level to reject payloads that contain
neither credential during validation time rather than failing later in the
service layer.

Source: Path instructions

Comment thread src/constants/common.js Outdated
Comment on lines +120 to +121
allowed_auth_mode: ['otp', 'password'],
auto_register: false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we take these from the env? We can store these in the envVariabels.js

DEFAULT_TENANT_CONFIGURATION: {
    allowed_auth_mode: process.env.DEFAULT_ALLOWED_AUTH_MODES
        ? process.env.DEFAULT_ALLOWED_AUTH_MODES.split(',')
        : ['otp', 'password'],
    auto_register: process.env.DEFAULT_AUTO_REGISTER === 'true' || false,
},

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/tenant.js Outdated
created_at: tenantCreateResponse?.created_at || new Date(),
updated_at: tenantCreateResponse?.updated_at || new Date(),
meta: tenantCreateResponse?.meta || {},
configuration: tenantCreateResponse?.configuration || common.DEFAULT_TENANT_CONFIGURATION,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove|| common.DEFAULT_TENANT_CONFIGURATION, we should only return back what is in the database, and since we are handling online no. 54, this is redundant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment on lines +11 to +55
const parseObject = (value) => {
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (e) {
return false
}
}

return value
}

const isPlainObject = (value) => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

const isValidObject = (value) => {
return isPlainObject(parseObject(value))
}

const isValidTenantConfiguration = (value) => {
const configuration = parseObject(value)
const allowedAuthModes = common.DEFAULT_TENANT_CONFIGURATION.allowed_auth_mode

return (
isPlainObject(configuration) &&
Array.isArray(configuration.allowed_auth_mode) &&
configuration.allowed_auth_mode.length > 0 &&
configuration.allowed_auth_mode.every((authMode) => allowedAuthModes.includes(authMode)) &&
typeof configuration.auto_register === 'boolean'
)
}

const normalizeTenantConfiguration = (req) => {
if (req.body.configuration === '') {
delete req.body.configuration
return
}

if (typeof req.body.configuration === 'string') {
const configuration = parseObject(req.body.configuration)
if (configuration) req.body.configuration = configuration
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great refactoring! The deduplication of the object validation logic is a solid improvement.

One small suggestion: a few of the extracted functions might be doing more than necessary. Specifically:

  • isPlainObject() is only used internally as a helper—could be inlined into isValidObject() for simplicity
  • isValidTenantConfiguration() is only used once for the configuration field. Since there's no duplication to eliminate here, extracting it adds a layer of indirection without much benefit

I'd suggest keeping just parseObject() and isValidObject() as extracted functions (they eliminate real duplication), and keeping the configuration validation logic inline:

req.checkBody('configuration')
	.optional({ checkFalsy: true })
	.custom((value) => {
		const configuration = parseObject(value)
		const allowedAuthModes = common.DEFAULT_TENANT_CONFIGURATION.allowed_auth_mode

		return (
			isValidObject(configuration) &&
			Array.isArray(configuration.allowed_auth_mode) &&
			configuration.allowed_auth_mode.length > 0 &&
			configuration.allowed_auth_mode.every((authMode) => allowedAuthModes.includes(authMode)) &&
			typeof configuration.auto_register === 'boolean'
		)
	})

This keeps the helper extraction minimal while still removing the duplication. Otherwise looks good! 👍

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change might not be required

Comment thread src/services/account.js
Comment on lines +43 to +97
const AUTH_MODES = common.AUTH_MODES
const OTP_PURPOSES = common.OTP_PURPOSES

function tenantConfiguration(tenantDetail) {
return {
...common.DEFAULT_TENANT_CONFIGURATION,
...(tenantDetail?.configuration || {}),
}
}

function failureResponse(message, statusCode = httpStatusCode.bad_request, responseCode = 'CLIENT_ERROR') {
return responses.failureResponse({
message,
statusCode,
responseCode,
})
}

function identifierType(identifier) {
if (/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(identifier)) return 'email'
if (/^\+?[1-9]\d{1,14}$/.test(identifier)) return 'phone'
if (/^[a-zA-Z0-9_-]{3,30}$/.test(identifier)) return 'username'
return null
}

function otpRedisKey(purpose, encryptedIdentifier) {
return `${purpose}:${encryptedIdentifier}`
}

function resolveOtpPurpose({ username, registrationCode, userExists }) {
if (username) return OTP_PURPOSES.LOGIN
if (registrationCode) return OTP_PURPOSES.SIGNUP
if (userExists) return OTP_PURPOSES.LOGIN
return OTP_PURPOSES.SIGNUP
}

async function getExistingOtpForPurpose(purpose, identifiers) {
for (const identifier of identifiers) {
const data = await utilsHelper.redisGet(otpRedisKey(purpose, identifier))
if (data && data.action === purpose) {
return data
}
}
return null
}

async function setOtpForPurpose(purpose, identifiers, redisData) {
const redisSetResults = []
for (const identifier of identifiers) {
const result = await utilsHelper.redisSet(otpRedisKey(purpose, identifier), redisData, common.otpExpirationTime)
redisSetResults.push(result)
}
return redisSetResults
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double-check this helper funcs and move the ones we discussed to helper/utils

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the changes, the functions for otp handling is still kept others have been moved

Comment thread src/services/account.js Outdated

// OTP validation
if (process.env.ENABLE_EMAIL_OTP_VERIFICATION === 'true') {
if (process.env.ENABLE_EMAIL_OTP_VERIFICATION === 'true' && allowedAuthMode.includes(AUTH_MODES.OTP)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove && allowedAuthMode.includes(AUTH_MODES.OTP)) as we need to verify the identity all the time

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/account.js
Comment on lines +145 to +151
if (bodyData.password && !passwordAuthAllowed) {
return failureResponse('AUTH_MODE_NOT_ALLOWED')
}

if (passwordAuthAllowed && !bodyData.password) {
return failureResponse('PASSWORD_REQUIRED')
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this to the validator?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently its better to keep it here, inorder to move it to validator it will require few changes as configurations are fetched from db
Will note this and pick it up later if thats fine

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, can we add a "#TODO:" comment so we can keep track of this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread src/services/account.js Outdated
Comment on lines +275 to +277
bodyData.username = await generateUniqueUsername(
bodyData.name || plaintextEmailId || plaintextPhoneNumber
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we not use plaintextEmailId || plaintextPhoneNumber since its PII

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/account.js Outdated
Comment on lines +799 to +804
if (type === 'email') {
encryptedIdentifier = emailEncryption.encrypt(identifier)
query[Op.or].push({ email: encryptedIdentifier })
} else if (type === 'phone') {
encryptedIdentifier = emailEncryption.encrypt(identifier)
query[Op.or].push({ phone: encryptedIdentifier, phone_code: bodyData.phone_code })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to hardcode the 'email and phone' in line 799 and 804

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/account.js
Comment on lines +145 to +151
if (bodyData.password && !passwordAuthAllowed) {
return failureResponse('AUTH_MODE_NOT_ALLOWED')
}

if (passwordAuthAllowed && !bodyData.password) {
return failureResponse('PASSWORD_REQUIRED')
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, can we add a "#TODO:" comment so we can keep track of this?

Comment thread src/services/account.js
const existingOtpData = await getExistingOtpForPurpose(purpose, redisIdentifiers)

const otp = existingOtpData ? existingOtpData.otp : utils.generateSecureOTP()
const isNew = !existingOtpData

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNew is computed but we always write to Redis regardless. Every resend call resets the OTP expiry, so it never actually expires. Should only write when it's a new OTP.

if (isNew) {
  const redisSetResults = await setOtpForPurpose(purpose, redisIdentifiers, redisData)
  if (!redisSetResults.includes('OK')) { ... }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/account.js
refreshToken
)

if (hasOtp) await utilsHelper.redisDel(otpRedisKey(OTP_PURPOSES.LOGIN, redisIdentifier))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

purpose is already in scope here, no need to hardcode OTP_PURPOSES.LOGIN. Minor but keeps it consistent if the logic above ever changes.

if (hasOtp) await utilsHelper.redisDel(otpRedisKey(purpose, redisIdentifier))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete OTP Redis key cleanup after OTP-based login leaves reusable keys

When a user logs in using OTP, only a single Redis key is deleted at src/services/account.js:951. However, registrationOtp stores the OTP at multiple Redis keys when the OTP is requested via username (e.g., login:<username>, login:<encryptedEmail>, login:<phoneCode+encryptedPhone> at lines 1360-1362). After a successful login using one identifier (e.g., username), the OTP remains valid under the other keys until TTL expiry. This allows the same OTP to be reused to create additional sessions for the same account via a different identifier within the TTL window.

@Vinod-V3 Vinod-V3 Jun 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done for purpose
For the otp deletion, this scenario might happen only for login via username
If its fine for now will make a note of it and take it up in the next release

Comment thread src/services/account.js
// Validate that at least one contact method is provided
if (!bodyData.email && !bodyData.phone) {
const config = tenantDetail.configuration || {}
const allowedAuthMode = config.allowed_auth_mode || []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this comes back as a non-array (bad config, migration issue), .includes() will throw. Small guard to be safe:

const allowedAuthMode = Array.isArray(config.allowed_auth_mode) 
  ? config.allowed_auth_mode 
  : []

Make the same changes in other occurrences as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Comment thread src/services/account.js
Comment on lines +809 to +811
if (!isOtpValid) {
return badRequestResponse('OTP_INVALID')
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an account gets created between OTP request and login (e.g. admin creates the user), the OTP is stored under SIGNUP but login looks under LOGIN — user gets OTP_INVALID with no way to recover except re-requesting. Worth documenting at minimum.

No code fix needed unless you want to handle it-in which case, check both keys on login:

const redisData = 
  await utilsHelper.redisGet(otpRedisKey(OTP_PURPOSES.LOGIN, redisIdentifier)) ||
  await utilsHelper.redisGet(otpRedisKey(OTP_PURPOSES.SIGNUP, redisIdentifier))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently not making this change, but have added a TODO comment to take it up for the next release

Comment thread src/generics/utils.js
function checkIdentifierType(identifier) {
if (/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(identifier)) return common.EMAIL
if (/^\+?[1-9]\d{1,14}$/.test(identifier)) return common.PHONE
if (/^[a-zA-Z0-9_-]{3,30}$/.test(identifier)) return common.USER_NAME

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove - since it wasn't present in the original regex?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this as system generated name might have -

Comment thread src/services/account.js
}

// Send SMS notification with OTP if phone is provided
if (plaintextPhoneNumber && bodyData.phone_code) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SMS notification skipped in username-based OTP flow due to using bodyData.phone_code instead of local phoneCode

In registrationOtp, when a user requests an OTP by providing their username, the SMS notification check at line 1450 uses bodyData.phone_code (from the request body) instead of the local phoneCode variable. The local phoneCode is correctly populated from the user's database record at src/services/account.js:1347, but the notification guard condition references bodyData.phone_code which will be undefined when only username is provided in the request (the validator makes phone_code optional). This causes the SMS notification to be silently skipped even when the user has a phone number on their account.

Comment thread src/services/account.js
refreshToken
)

if (hasOtp) await utilsHelper.redisDel(otpRedisKey(OTP_PURPOSES.LOGIN, redisIdentifier))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete OTP Redis key cleanup after OTP-based login leaves reusable keys

When a user logs in using OTP, only a single Redis key is deleted at src/services/account.js:951. However, registrationOtp stores the OTP at multiple Redis keys when the OTP is requested via username (e.g., login:<username>, login:<encryptedEmail>, login:<phoneCode+encryptedPhone> at lines 1360-1362). After a successful login using one identifier (e.g., username), the OTP remains valid under the other keys until TTL expiry. This allows the same OTP to be reused to create additional sessions for the same account via a different identifier within the TTL window.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should get the list of tenants instead of orgs; a new entity type should be inserted for only the default org (process.env....) of the available tenants since default org data is available to other orgs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants