From c2cea8cffc26b930bb5625872b7285f5390a5585 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 8 Aug 2025 18:20:47 -0300 Subject: [PATCH 1/5] Fix SAML duplicate accounts when signing in the first time on an existing account --- ...174003_add-user-alias-is-email-verified.ts | 37 +++++++++++++++++++ backend/src/db/schemas/user-aliases.ts | 3 +- .../saml-config/saml-config-service.ts | 29 +++++++-------- backend/src/server/routes/index.ts | 3 +- backend/src/server/routes/v2/user-router.ts | 5 ++- backend/src/services/user/user-service.ts | 26 +++++++++++-- 6 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts diff --git a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts new file mode 100644 index 000000000..62322afb8 --- /dev/null +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -0,0 +1,37 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) { + // Add the column + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + + const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases) + .join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`) + .select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]); + + for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) { + const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE); + + const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId); + + if (trueIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true }); + } + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.dropColumn("isEmailVerified"); + }); + } +} diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index 14147abf8..428fa62ca 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({ emails: z.string().array().nullable().optional(), orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUserAliases = z.infer; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index cbb99f7eb..270462165 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({ }); } - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.SAML @@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustSamlEmails) { - newUser = await userDAL.findOne( - { - email, - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); @@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustSamlEmails }, tx ); @@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified); const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -440,7 +439,7 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 90e961c29..82911ff53 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -727,7 +727,8 @@ export const registerRoutes = async ( permissionService, groupProjectDAL, smtpService, - projectMembershipDAL + projectMembershipDAL, + userAliasDAL }); const totpService = totpServiceFactory({ diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 92b4138f2..f4b02e7be 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -17,6 +17,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }) }, schema: { + headers: z.object({ + referer: z.string().trim() + }), body: z.object({ username: z.string().trim() }), @@ -25,7 +28,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username); + await server.services.user.sendEmailVerificationCode(req.body.username, req.headers.referer); return {}; } }); diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 30bf750c3..55c9de185 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -12,6 +13,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { AuthMethod } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -37,6 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -48,17 +51,28 @@ export const userServiceFactory = ({ groupProjectDAL, tokenService, smtpService, - permissionService + permissionService, + userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string) => { + const sendEmailVerificationCode = async (username: string, referer: string) => { // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + let { isEmailVerified } = user; + const url = new URL(referer); + const refererToken = url.searchParams.get("token"); + if (!refererToken) + throw new BadRequestError({ name: "Failed to send email verification code due to no token on referer" }); + const { authType } = crypto.jwt().decode(refererToken) as { authType: string }; + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType }); + if (userAlias) { + isEmailVerified = userAlias.isEmailVerified; + } if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); - if (user.isEmailVerified) + if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); const token = await tokenService.createTokenForUser({ @@ -95,7 +109,9 @@ export const userServiceFactory = ({ if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - if (user.isEmailVerified) + + const userAliases = await userAliasDAL.find({ userId: user.id }); + if (user.isEmailVerified && userAliases?.every((alias) => alias.isEmailVerified)) throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); await tokenService.validateTokenForUser({ @@ -106,6 +122,8 @@ export const userServiceFactory = ({ const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; + await userAliasDAL.update({ userId: user.id }, { isEmailVerified: true }); + await userDAL.updateById(user.id, { isEmailVerified: true, username: userEmails?.length === 1 && userEmails?.[0]?.id === user.id ? user.email.toLowerCase() : undefined From 60b3f5c7c68ce72096e077031306d3f2c4e470fe Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 11 Aug 2025 13:24:31 -0700 Subject: [PATCH 2/5] Improve user alias check logic and header usage on resend code --- .../saml-config/saml-config-service.ts | 1 + backend/src/server/routes/v2/user-router.ts | 5 +---- backend/src/services/user/user-service.ts | 18 +++++++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 270462165..df945d950 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -423,6 +423,7 @@ export const samlConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: authProvider, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authType: UserAliasType.SAML, isUserCompleted, ...(relayState diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index f4b02e7be..f7c0a11d3 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -20,15 +20,12 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { headers: z.object({ referer: z.string().trim() }), - body: z.object({ - username: z.string().trim() - }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username, req.headers.referer); + await server.services.user.sendEmailVerificationCode(req.headers.referer); return {}; } }); diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 55c9de185..6b450db62 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -54,18 +54,22 @@ export const userServiceFactory = ({ permissionService, userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string, referer: string) => { + const sendEmailVerificationCode = async (referer: string) => { + const url = new URL(referer); + const refererToken = url.searchParams.get("token"); + if (!refererToken) + throw new BadRequestError({ name: "Failed to send email verification code due to no token on referer" }); + const { authType, aliasId, username } = crypto.jwt().decode(refererToken) as { + authType: string; + aliasId: string; + username: string; + }; // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); let { isEmailVerified } = user; - const url = new URL(referer); - const refererToken = url.searchParams.get("token"); - if (!refererToken) - throw new BadRequestError({ name: "Failed to send email verification code due to no token on referer" }); - const { authType } = crypto.jwt().decode(refererToken) as { authType: string }; - const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType }); + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); if (userAlias) { isEmailVerified = userAlias.isEmailVerified; } From 8a72023e8029f901da93b5310c405dd3d14490a1 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 12 Aug 2025 18:58:23 -0700 Subject: [PATCH 3/5] Improve verification and resend code logic, added oidc and ldap --- ...174003_add-user-alias-is-email-verified.ts | 12 +++++ backend/src/db/schemas/auth-tokens.ts | 3 +- .../ldap-config/ldap-config-service.ts | 30 +++++------ .../ee/services/oidc/oidc-config-service.ts | 52 +++++++++---------- .../saml-config/saml-config-service.ts | 3 +- backend/src/server/routes/v2/user-router.ts | 6 +-- .../services/auth-token/auth-token-service.ts | 5 +- .../services/auth-token/auth-token-types.ts | 1 + backend/src/services/user/user-service.ts | 44 ++++++++-------- frontend/src/hooks/api/users/mutation.tsx | 8 +-- .../EmailConfirmationStep.tsx | 11 +++- 11 files changed, 101 insertions(+), 74 deletions(-) diff --git a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts index 62322afb8..03a45b1fd 100644 --- a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -26,6 +26,12 @@ export async function up(knex: Knex): Promise { } } } + + if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.string("aliasId").nullable(); + }); + } } export async function down(knex: Knex): Promise { @@ -34,4 +40,10 @@ export async function down(knex: Knex): Promise { t.dropColumn("isEmailVerified"); }); } + + if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("aliasId"); + }); + } } diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index dd8563b85..0d3e93219 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional() + orgId: z.string().uuid().nullable().optional(), + aliasId: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 338099da9..a72d50760 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({ userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustLdapEmails) { - newUser = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(username, userDAL); @@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({ aliasType: UserAliasType.LDAP, externalId, emails: [email], - orgId + orgId, + isEmailVerified: serverCfg.trustLdapEmails }, tx ); @@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({ return newUser; }); - const isUserCompleted = Boolean(user.isAccepted); - + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, hasExchangedPrivateKey: true, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, authType: UserAliasType.LDAP, + aliasId: userAlias.id, isUserCompleted, ...(relayState ? { @@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({ } ); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b9cf011d9..8f479b12c 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({ } const appCfg = getConfig(); - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.OIDC @@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + // we prioritize getting the most complete user to create the new alias under + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); - if (serverCfg.trustOidcEmails) { - // we prioritize getting the most complete user to create the new alias under + if (!newUser) { + // this fetches user entries created via invites newUser = await userDAL.findOne( { - email, - isEmailVerified: true + username: email }, tx ); - if (!newUser) { - // this fetches user entries created via invites - newUser = await userDAL.findOne( - { - username: email - }, - tx - ); - - if (newUser && !newUser.isEmailVerified) { - // we automatically mark it as email-verified because we've configured trust for OIDC emails - newUser = await userDAL.updateById(newUser.id, { - isEmailVerified: true - }); - } + if (newUser && !newUser.isEmailVerified) { + // we automatically mark it as email-verified because we've configured trust for OIDC emails + newUser = await userDAL.updateById(newUser.id, { + isEmailVerified: serverCfg.trustOidcEmails + }); } } @@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustOidcEmails }, tx ); @@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({ await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, isUserCompleted, @@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({ await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index df945d950..6b8bbe304 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -443,7 +443,8 @@ export const samlConfigServiceFactory = ({ if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index f7c0a11d3..f416fe8bb 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -17,15 +17,15 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }) }, schema: { - headers: z.object({ - referer: z.string().trim() + body: z.object({ + token: z.string().trim() }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.headers.referer); + await server.services.user.sendEmailVerificationCode(req.body.token); return {}; } }); diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 1a2f290ec..c309b3998 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu type, userId, orgId, - triesLeft: tkCfg?.triesLeft + triesLeft: tkCfg?.triesLeft, + aliasId }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 5f5843bc6..7deb719a9 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = { type: TokenType; userId: string; orgId?: string; + aliasId?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 6b450db62..8bbef0256 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -10,7 +10,7 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; @@ -39,7 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; - userAliasDAL: Pick; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -54,23 +54,23 @@ export const userServiceFactory = ({ permissionService, userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (referer: string) => { - const url = new URL(referer); - const refererToken = url.searchParams.get("token"); - if (!refererToken) - throw new BadRequestError({ name: "Failed to send email verification code due to no token on referer" }); - const { authType, aliasId, username } = crypto.jwt().decode(refererToken) as { + const sendEmailVerificationCode = async (token: string) => { + const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as { authType: string; - aliasId: string; + aliasId?: string; username: string; + authTokenType: AuthTokenType; }; + if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" }); + // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); let { isEmailVerified } = user; - const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); - if (userAlias) { + if (aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` }); isEmailVerified = userAlias.isEmailVerified; } @@ -79,9 +79,10 @@ export const userServiceFactory = ({ if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); - const token = await tokenService.createTokenForUser({ + const userToken = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId }); await smtpService.sendMail({ @@ -89,7 +90,7 @@ export const userServiceFactory = ({ subjectLine: "Infisical confirmation code", recipients: [user.email], substitutions: { - code: token + code: userToken } }); }; @@ -114,19 +115,20 @@ export const userServiceFactory = ({ if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - const userAliases = await userAliasDAL.find({ userId: user.id }); - if (user.isEmailVerified && userAliases?.every((alias) => alias.isEmailVerified)) - throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - - await tokenService.validateTokenForUser({ + const token = await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id, code }); - const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; + if (token?.aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (userAlias?.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - await userAliasDAL.update({ userId: user.id }, { isEmailVerified: true }); + await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true }); + } + const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; await userDAL.updateById(user.id, { isEmailVerified: true, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 84bd05e07..b108a98c4 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => { }); }; -export const sendEmailVerificationCode = async (username: string) => { +export const sendEmailVerificationCode = async (token: string) => { return apiRequest.post("/api/v2/users/me/emails/code", { - username + token }); }; export const useSendEmailVerificationCode = () => { return useMutation({ - mutationFn: async (username: string) => { - await sendEmailVerificationCode(username); + mutationFn: async (token: string) => { + await sendEmailVerificationCode(token); return {}; } }); diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx index a200d3f7c..983b1ec61 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({ const resendCode = async () => { try { - await sendEmailVerificationCode(username); + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get("token"); + if (!token) { + createNotification({ + text: "Failed to resend code, no token found", + type: "error" + }); + return; + } + await sendEmailVerificationCode(token); createNotification({ text: "Successfully resent code", type: "success" From bb14231d711afa32bbb53e76ce695378b18c2aba Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 18 Aug 2025 11:06:11 +0800 Subject: [PATCH 4/5] Throw an error when org authEnforced is enabled and user is trying to select org --- backend/src/services/auth/auth-login-service.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6a3456508..1f986bb72 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -451,6 +451,13 @@ export const authLoginServiceFactory = ({ const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); const selectedOrg = await orgDAL.findById(organizationId); + // Check if authEnforced is true, if that's the case, throw an error + if (selectedOrg.authEnforced) { + throw new BadRequestError({ + message: "Authentication is required by your organization before you can log in." + }); + } + if (!hasOrganizationMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` From 52bbe25fc5a58950c331bb89b8448efad31fb938 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 19 Aug 2025 14:27:26 +0800 Subject: [PATCH 5/5] Add userAlias check --- backend/src/services/user/user-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 8bbef0256..b0d7be0fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -123,6 +123,7 @@ export const userServiceFactory = ({ if (token?.aliasId) { const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` }); if (userAlias?.isEmailVerified) throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });