diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 07923fd6c..4dbf9ac0b 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -108,6 +108,74 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/select-sub-organization", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + subOrganizationId: z.string().trim(), + userAgent: z.enum(["cli"]).optional() + }), + response: { + 200: z.object({ + token: z.string(), + isMfaEnabled: z.boolean(), + mfaMethod: z.string().optional(), + subOrganization: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .optional() + }) + } + }, + handler: async (req, res) => { + const cfg = getConfig(); + const result = await server.services.login.selectSubOrganization({ + userAgent: req.body.userAgent ?? req.headers["user-agent"], + authJwtToken: req.headers.authorization, + subOrganizationId: req.body.subOrganizationId, + ipAddress: req.realIp + }); + + if (result.isMfaEnabled) { + return { + token: result.mfa as string, + isMfaEnabled: true, + mfaMethod: result.mfaMethod + }; + } + + void res.setCookie("jid", result.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED + }); + + addAuthOriginDomainCookie(res); + + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED, + maxAge: 0 + }); + + return { + token: result.access, + isMfaEnabled: false, + subOrganization: result.subOrganization + }; + } + }); + server.route({ method: "POST", url: "/login2", diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 8e0f654a3..aec271ec9 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -142,6 +142,7 @@ export const authLoginServiceFactory = ({ ip, userAgent, organizationId, + subOrganizationId, authMethod, isMfaVerified, mfaMethod @@ -150,6 +151,7 @@ export const authLoginServiceFactory = ({ ip: string; userAgent: string; organizationId?: string; + subOrganizationId?: string; authMethod: AuthMethod; isMfaVerified?: boolean; mfaMethod?: MfaMethod; @@ -193,6 +195,7 @@ export const authLoginServiceFactory = ({ tokenVersionId: tokenSession.id, accessVersion: tokenSession.accessVersion, organizationId, + subOrganizationId, isMfaVerified, mfaMethod }, @@ -208,6 +211,7 @@ export const authLoginServiceFactory = ({ tokenVersionId: tokenSession.id, refreshVersion: tokenSession.refreshVersion, organizationId, + subOrganizationId, isMfaVerified, mfaMethod }, @@ -701,6 +705,141 @@ export const authLoginServiceFactory = ({ }; }; + const selectSubOrganization = async ({ + userAgent, + authJwtToken, + ipAddress, + subOrganizationId + }: { + userAgent: string | undefined; + authJwtToken: string | undefined; + ipAddress: string; + subOrganizationId: string; + }) => { + const cfg = getConfig(); + + if (!authJwtToken) throw new UnauthorizedError({ name: "Authorization header is required" }); + if (!userAgent) throw new UnauthorizedError({ name: "User-Agent header is required" }); + + // eslint-disable-next-line no-param-reassign + authJwtToken = authJwtToken.replace("Bearer ", ""); + + const decodedToken = crypto.jwt().verify(authJwtToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload; + if (!decodedToken.authMethod) throw new UnauthorizedError({ name: "Auth method not found on existing token" }); + if (!decodedToken.organizationId) + throw new BadRequestError({ message: "No organization selected in current token" }); + + const user = await userDAL.findUserEncKeyByUserId(decodedToken.userId); + if (!user) throw new BadRequestError({ message: "User not found", name: "Find user from token" }); + + // Fetch the sub-organization + const subOrg = await orgDAL.findById(subOrganizationId); + if (!subOrg) { + throw new BadRequestError({ message: `Sub-organization with ID ${subOrganizationId} not found` }); + } + + // Verify this is actually a sub-organization of the current root org + if (subOrg.rootOrgId !== decodedToken.organizationId && subOrg.id !== decodedToken.organizationId) { + throw new ForbiddenRequestError({ + message: "Sub-organization does not belong to the current organization" + }); + } + + // Check user membership in the sub-organization + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: subOrganizationId, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User is not a member of this sub-organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User membership in sub-organization is inactive" }); + } + + // Check MFA requirements for the sub-organization + const shouldCheckMfa = subOrg.enforceMfa || user.isMfaEnabled; + const orgMfaMethod = subOrg.enforceMfa ? (subOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; + const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; + const mfaMethod = orgMfaMethod ?? userMfaMethod; + + if (shouldCheckMfa && (!decodedToken.isMfaVerified || decodedToken.mfaMethod !== mfaMethod)) { + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + + const mfaToken = crypto.jwt().sign( + { + authMethod: decodedToken.authMethod, + authTokenType: AuthTokenType.MFA_TOKEN, + userId: user.id + }, + cfg.AUTH_SECRET, + { + expiresIn: cfg.JWT_MFA_LIFETIME + } + ); + + if (mfaMethod === MfaMethod.EMAIL && user.email) { + await sendUserMfaCode({ + userId: user.id, + email: user.email + }); + } + + return { isMfaEnabled: true, mfa: mfaToken, mfaMethod } as const; + } + + // Generate tokens scoped to the sub-organization + const tokens = await generateUserTokens({ + authMethod: decodedToken.authMethod, + user, + userAgent, + ip: ipAddress, + organizationId: decodedToken.organizationId, // Keep root org ID + subOrganizationId, // Add sub-org ID + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod + }); + + // Create audit log for sub-organization selection + await auditLogService.createAuditLog({ + orgId: subOrganizationId, + ipAddress, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: user.email, + userId: user.id, + username: user.username, + authMethod: decodedToken.authMethod + } + }, + event: { + type: EventType.SELECT_SUB_ORGANIZATION, + metadata: { + organizationId: subOrganizationId, + organizationName: subOrg.name, + parentOrganizationId: decodedToken.organizationId + } + } + }); + + return { + ...tokens, + user, + isMfaEnabled: false, + subOrganization: { + id: subOrg.id, + name: subOrg.name, + slug: subOrg.slug + } + }; + }; + /* * Multi factor authentication re-send code, Get user id from token * saved in frontend @@ -1134,6 +1273,7 @@ export const authLoginServiceFactory = ({ resendMfaToken, verifyMfaToken, selectOrganization, + selectSubOrganization, generateUserTokens, login }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 207980017..be81612f2 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -107,6 +107,49 @@ export const useSelectOrganization = () => { }); }; +export const selectSubOrganization = async (data: { + subOrganizationId: string; + userAgent?: UserAgentType; +}) => { + const { data: res } = await apiRequest.post<{ + token: string; + isMfaEnabled: boolean; + mfaMethod?: MfaMethod; + subOrganization?: { + id: string; + name: string; + slug: string; + }; + }>("/api/v3/auth/select-sub-organization", data); + return res; +}; + +export const useSelectSubOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (details: { subOrganizationId: string; userAgent?: UserAgentType }) => { + const data = await selectSubOrganization(details); + + // If a custom user agent is set, then this session is meant for another consuming application, not the web application. + if (!details.userAgent && !data.isMfaEnabled) { + SecurityClient.setToken(data.token); + SecurityClient.setProviderAuthToken(""); + } + + if (data.token && !data.isMfaEnabled) { + setAuthToken(data.token); + } + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [organizationKeys.getUserOrganizations, projectKeys.getAllUserProjects] + }); + } + }); +}; + export const useLogin2 = () => { return useMutation({ mutationFn: async (details: { diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 93d08f9ca..cb0a738f8 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -196,6 +196,8 @@ export const Navbar = () => { const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; const handleOrgChange = async (orgId: string, onSuccess?: () => void | Promise) => { + if (orgId === currentOrg.id) return; + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId: orgId }); @@ -243,6 +245,8 @@ export const Navbar = () => { }; const handleSubOrgChange = async (subOrgId: string) => { + if (subOrgId === currentOrg.id) return; + const { token, isMfaEnabled, mfaMethod } = await selectSubOrganization({ subOrganizationId: subOrgId });