diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index d0d1bc06c..1b162ca16 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2693,7 +2693,7 @@ interface SelectSubOrganizationEvent { metadata: { organizationId: string; organizationName: string; - parentOrganizationId: string; + rootOrganizationId: string; }; } diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 4dbf9ac0b..13b9b358c 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -43,10 +43,15 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { rateLimit: authRateLimit }, schema: { - body: z.object({ - organizationId: z.string().trim(), - userAgent: z.enum(["cli"]).optional() - }), + body: z + .object({ + organizationId: z.string().trim().optional(), + subOrganizationId: z.string().trim().optional(), + userAgent: z.enum(["cli"]).optional() + }) + .refine((body) => Boolean(body.organizationId || body.subOrganizationId), { + message: "organizationId or subOrganizationId is required" + }), response: { 200: z.object({ token: z.string(), @@ -57,12 +62,25 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const cfg = getConfig(); - const tokens = await server.services.login.selectOrganization({ - userAgent: req.body.userAgent ?? req.headers["user-agent"], - authJwtToken: req.headers.authorization, - organizationId: req.body.organizationId, - ipAddress: req.realIp - }); + let tokens; + + const targetOrgId = req.body.subOrganizationId ?? req.body.organizationId ?? ""; + + if (req.body.subOrganizationId) { + tokens = await server.services.login.selectSubOrganization({ + userAgent: req.body.userAgent ?? req.headers["user-agent"], + authJwtToken: req.headers.authorization, + subOrganizationId: req.body.subOrganizationId, + ipAddress: req.realIp + }); + } else { + tokens = await server.services.login.selectOrganization({ + userAgent: req.body.userAgent ?? req.headers["user-agent"], + authJwtToken: req.headers.authorization, + organizationId: req.body.organizationId as string, + ipAddress: req.realIp + }); + } if (tokens.isMfaEnabled) { return { @@ -75,7 +93,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { const githubOauthAccessToken = req.cookies[INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN]; if (githubOauthAccessToken) { await server.services.githubOrgSync - .syncUserGroups(req.body.organizationId, tokens.user.userId, githubOauthAccessToken) + .syncUserGroups(targetOrgId, tokens.user.userId, githubOauthAccessToken) .finally(() => { void res.setCookie(INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN, "", { httpOnly: true, @@ -108,74 +126,6 @@ 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 aec271ec9..4c063e547 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -725,39 +725,58 @@ export const authLoginServiceFactory = ({ 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" }); + // Check user membership in the sub-organization + const userSubOrgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: subOrganizationId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted + }); + // 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) { + if (!userSubOrgMembership) { throw new ForbiddenRequestError({ - message: "Sub-organization does not belong to the current organization" + message: `User does not have access to the sub-organization named ${subOrg.name}` }); } - // Check user membership in the sub-organization - const orgMembership = await membershipUserDAL.findOne({ - actorUserId: user.id, - scopeOrgId: subOrganizationId, - scope: AccessScope.Organization - }); + const subOrgmembershipRole = await membershipRoleDAL.findOne({ membershipId: userSubOrgMembership.id }); - if (!orgMembership) { - throw new ForbiddenRequestError({ message: "User is not a member of this sub-organization" }); + // Check if authEnforced is true and the current auth method is not an enforced method + if ( + subOrg.authEnforced && + !isAuthMethodSaml(decodedToken.authMethod) && + decodedToken.authMethod !== AuthMethod.OIDC && + !(subOrg.bypassOrgAuthEnabled && subOrgmembershipRole.role === OrgMembershipRole.Admin) + ) { + throw new BadRequestError({ + message: "Login with the auth method required by your organization." + }); } - if (!orgMembership.isActive) { - throw new ForbiddenRequestError({ message: "User membership in sub-organization is inactive" }); + if (subOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = subOrg.bypassOrgAuthEnabled && subOrgmembershipRole.role === OrgMembershipRole.Admin; + + if (!canBypass) { + throw new ForbiddenRequestError({ + message: "Google SSO is enforced for this organization. Please use Google SSO to login.", + error: "GoogleSsoEnforced" + }); + } + } + + if (decodedToken.authMethod === AuthMethod.GOOGLE) { + await orgDAL.updateById(subOrg.id, { + googleSsoAuthLastUsed: new Date() + }); } // Check MFA requirements for the sub-organization @@ -797,8 +816,8 @@ export const authLoginServiceFactory = ({ user, userAgent, ip: ipAddress, - organizationId: decodedToken.organizationId, // Keep root org ID - subOrganizationId, // Add sub-org ID + ...(subOrg.rootOrgId && { organizationId: subOrg.rootOrgId }), + subOrganizationId, isMfaVerified: decodedToken.isMfaVerified, mfaMethod: decodedToken.mfaMethod }); @@ -823,7 +842,7 @@ export const authLoginServiceFactory = ({ metadata: { organizationId: subOrganizationId, organizationName: subOrg.name, - parentOrganizationId: decodedToken.organizationId + rootOrganizationId: subOrg.rootOrgId ?? "" } } }); @@ -831,12 +850,7 @@ export const authLoginServiceFactory = ({ return { ...tokens, user, - isMfaEnabled: false, - subOrganization: { - id: subOrg.id, - name: subOrg.name, - slug: subOrg.slug - } + isMfaEnabled: false }; }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index be81612f2..bac8815ab 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -58,10 +58,17 @@ export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { return data; }; -export const selectOrganization = async (data: { - organizationId: string; - userAgent?: UserAgentType; -}) => { +export type SelectOrganizationParams = + | { + organizationId: string; + userAgent?: UserAgentType; + } + | { + subOrganizationId: string; + userAgent?: UserAgentType; + }; + +export const selectOrganization = async (data: SelectOrganizationParams) => { const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean; @@ -73,7 +80,7 @@ export const selectOrganization = async (data: { export const useSelectOrganization = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => { + mutationFn: async (details: SelectOrganizationParams) => { const data = await selectOrganization(details); // If a custom user agent is set, then this session is meant for another consuming application, not the web application. @@ -107,49 +114,6 @@ 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 42d0e92c0..c664f7aec 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -62,7 +62,11 @@ import { useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; -import { authKeys, selectOrganization, selectSubOrganization } from "@app/hooks/api/auth/queries"; +import { + authKeys, + selectOrganization, + type SelectOrganizationParams +} from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { getAuthToken } from "@app/hooks/api/reactQuery"; import { Organization, SubscriptionPlan } from "@app/hooks/api/types"; @@ -191,12 +195,26 @@ export const Navbar = () => { } }, [subscription, isBillingPage, isModalIntrusive]); - const handleOrgChange = async (orgId: string, onSuccess?: () => void | Promise) => { - if (orgId === currentOrg.id) return; + const handleOrgSelection = async ({ + organizationId, + subOrganizationId, + onSuccess + }: { + organizationId?: string; + subOrganizationId?: string; + onSuccess?: () => void | Promise; + }) => { + if (!organizationId && !subOrganizationId) return; - const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ - organizationId: orgId - }); + const targetId = subOrganizationId ?? organizationId; + + if (targetId === currentOrg.id) return; + + const selectionPayload: SelectOrganizationParams = subOrganizationId + ? { subOrganizationId } + : { organizationId: organizationId as string }; + + const { token, isMfaEnabled, mfaMethod } = await selectOrganization(selectionPayload); if (isMfaEnabled) { SecurityClient.setMfaToken(token); @@ -205,7 +223,7 @@ export const Navbar = () => { } toggleShowMfa.on(); setMfaSuccessCallback(() => async () => { - await handleOrgChange(orgId, onSuccess); + await handleOrgSelection({ organizationId, subOrganizationId, onSuccess }); }); return; } @@ -216,7 +234,7 @@ export const Navbar = () => { queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); await router.invalidate(); - await navigateUserToOrg(navigate, orgId); + await navigateUserToOrg(navigate, targetId); queryClient.removeQueries({ queryKey: subOrgQuery.queryKey }); if (onSuccess) { @@ -234,43 +252,12 @@ export const Navbar = () => { }; if (currentOrg.id !== rootOrg.id) { - await handleOrgChange(rootOrg.id, navigateToBilling); + await handleOrgSelection({ organizationId: rootOrg.id, onSuccess: navigateToBilling }); } else { await navigateToBilling(); } }; - const handleSubOrgChange = async (subOrgId: string) => { - if (subOrgId === currentOrg.id) return; - - const { token, isMfaEnabled, mfaMethod } = await selectSubOrganization({ - subOrganizationId: subOrgId - }); - - localStorage.setItem("orgData.id", subOrgId); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(token); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleSubOrgChange(subOrgId)); - return; - } - - SecurityClient.setToken(token); - SecurityClient.setProviderAuthToken(""); - - queryClient.removeQueries({ queryKey: authKeys.getAuthToken }); - queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); - await router.invalidate(); - navigate({ - to: "/organizations/$orgId/projects", - params: { orgId: subOrgId } - }); - }; - const { mutateAsync } = useGetOrgTrialUrl(); const logout = useLogoutUser(); @@ -340,7 +327,7 @@ export const Navbar = () => { return; } - handleOrgChange(org?.id); + handleOrgSelection({ organizationId: org?.id }); }; return ( @@ -465,7 +452,7 @@ export const Navbar = () => { {subOrganizations.map((subOrg) => ( handleSubOrgChange(subOrg.id)} + onClick={() => handleOrgSelection({ subOrganizationId: subOrg.id })} className="cursor-pointer font-normal" key={subOrg.id} > @@ -563,7 +550,7 @@ export const Navbar = () => { {subOrganizations.map((subOrg) => ( handleSubOrgChange(subOrg.id)} + onClick={() => handleOrgSelection({ subOrganizationId: subOrg.id })} className="cursor-pointer font-normal" key={subOrg.id} >