diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 13b9b358c..0f77c02f6 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -43,15 +43,10 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { rateLimit: authRateLimit }, schema: { - 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" - }), + body: z.object({ + organizationId: z.string().trim(), + userAgent: z.enum(["cli"]).optional() + }), response: { 200: z.object({ token: z.string(), @@ -62,25 +57,13 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const cfg = getConfig(); - 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 - }); - } + 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 + }); if (tokens.isMfaEnabled) { return { @@ -93,7 +76,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { const githubOauthAccessToken = req.cookies[INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN]; if (githubOauthAccessToken) { await server.services.githubOrgSync - .syncUserGroups(targetOrgId, tokens.user.userId, githubOauthAccessToken) + .syncUserGroups(req.body.organizationId, tokens.user.userId, githubOauthAccessToken) .finally(() => { void res.setCookie(INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN, "", { httpOnly: true, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 374aef30d..78bf44253 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -13,7 +13,13 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { crypto, generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + DatabaseError, + ForbiddenRequestError, + NotFoundError, + UnauthorizedError +} from "@app/lib/errors"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; @@ -530,25 +536,77 @@ export const authLoginServiceFactory = ({ const user = await userDAL.findUserEncKeyByUserId(decodedToken.userId); if (!user) throw new BadRequestError({ message: "User not found", name: "Find user from token" }); - // Check if the user actually has access to the specified organization. - const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - - const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); - const selectedOrg = await orgDAL.findById(organizationId); - - if (!selectedOrgMembership) { - throw new ForbiddenRequestError({ - message: `User does not have access to the organization named ${selectedOrg?.name}` - }); + if (!selectedOrg) { + throw new NotFoundError({ message: `Organization with ID '${organizationId}' not found` }); + } + + const isSubOrganization = Boolean(selectedOrg.rootOrgId && selectedOrg.id !== selectedOrg.rootOrgId); + + let rootOrg = selectedOrg; + let membershipRole; + + if (isSubOrganization) { + if (!selectedOrg.rootOrgId) { + throw new BadRequestError({ + message: "Invalid sub-organization" + }); + } + + rootOrg = await orgDAL.findById(selectedOrg.rootOrgId); + if (!rootOrg) { + throw new BadRequestError({ + message: "Invalid root organization" + }); + } + + // Check user membership in the sub-organization + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: organizationId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ + message: `User does not have access to the sub-organization named ${selectedOrg.name}` + }); + } + + // Check user membership in the root organization + const rootOrgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: rootOrg.id, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted + }); + + if (!rootOrgMembership) { + throw new ForbiddenRequestError({ + message: "User does not have access to the root organization" + }); + } + + membershipRole = (await membershipRoleDAL.findOne({ membershipId: orgMembership.id })).role; + } else { + // For root organizations, check membership using the existing method + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + + if (!selectedOrgMembership) { + throw new ForbiddenRequestError({ + message: `User does not have access to the organization named ${selectedOrg.name}` + }); + } + membershipRole = selectedOrgMembership.userRole; } - // Check if authEnforced is true and the current auth method is not an enforced method if ( selectedOrg.authEnforced && !isAuthMethodSaml(decodedToken.authMethod) && decodedToken.authMethod !== AuthMethod.OIDC && - !(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin) + !(selectedOrg.bypassOrgAuthEnabled && membershipRole === OrgMembershipRole.Admin) ) { throw new BadRequestError({ message: "Login with the auth method required by your organization." @@ -556,7 +614,7 @@ export const authLoginServiceFactory = ({ } if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { - const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + const canBypass = selectedOrg.bypassOrgAuthEnabled && membershipRole === OrgMembershipRole.Admin; if (!canBypass) { throw new ForbiddenRequestError({ @@ -607,7 +665,8 @@ export const authLoginServiceFactory = ({ user, userAgent, ip: ipAddress, - organizationId, + organizationId: isSubOrganization ? rootOrg.id : organizationId, + subOrganizationId: isSubOrganization ? organizationId : undefined, isMfaVerified: decodedToken.isMfaVerified, mfaMethod: decodedToken.mfaMethod }); @@ -675,205 +734,55 @@ export const authLoginServiceFactory = ({ } } - await auditLogService.createAuditLog({ - orgId: organizationId, - 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_ORGANIZATION, - metadata: { - organizationId, - organizationName: selectedOrg.name - } - } - }); - - return { - ...tokens, - user, - isMfaEnabled: false - }; - }; - - 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" }); - - 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 (!userSubOrgMembership) { - throw new ForbiddenRequestError({ - message: `User does not have access to the sub-organization named ${subOrg.name}` - }); - } - - if (!subOrg.rootOrgId) { - throw new BadRequestError({ - message: "Invalid sub-organization" - }); - } - - const rootOrg = await orgDAL.findById(subOrg.rootOrgId); - - if (!rootOrg) { - throw new BadRequestError({ - message: "Invalid root organization" - }); - } - - const rootOrgMembership = await membershipUserDAL.findOne({ - actorUserId: user.id, - scopeOrgId: rootOrg.id, - scope: AccessScope.Organization, - status: OrgMembershipStatus.Accepted - }); - - if (!rootOrgMembership) { - throw new ForbiddenRequestError({ - message: "User does not have access to the root organization" - }); - } - - const subOrgmembershipRole = await membershipRoleDAL.findOne({ membershipId: userSubOrgMembership.id }); - - // 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 (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 - 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 + // Create audit log for organization selection + if (isSubOrganization) { + await auditLogService.createAuditLog({ + orgId: organizationId, + ipAddress, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: user.email, + userId: user.id, + username: user.username, + authMethod: decodedToken.authMethod + } }, - cfg.AUTH_SECRET, - { - expiresIn: cfg.JWT_MFA_LIFETIME + event: { + type: EventType.SELECT_SUB_ORGANIZATION, + metadata: { + organizationId, + organizationName: selectedOrg.name, + rootOrganizationId: rootOrg.id + } } - ); - - if (mfaMethod === MfaMethod.EMAIL && user.email) { - await sendUserMfaCode({ - userId: user.id, - email: user.email - }); - } - - return { isMfaEnabled: true, mfa: mfaToken, mfaMethod } as const; + }); + } else { + await auditLogService.createAuditLog({ + orgId: organizationId, + 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_ORGANIZATION, + metadata: { + organizationId, + organizationName: selectedOrg.name + } + } + }); } - - // Generate tokens scoped to the sub-organization - const tokens = await generateUserTokens({ - authMethod: decodedToken.authMethod, - user, - userAgent, - ip: ipAddress, - organizationId: rootOrg.id, - subOrganizationId, - 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, - rootOrganizationId: subOrg.rootOrgId ?? "" - } - } - }); - return { ...tokens, user, @@ -1314,7 +1223,6 @@ 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 bac8815ab..e4355ddc7 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -58,15 +58,10 @@ export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { return data; }; -export type SelectOrganizationParams = - | { - organizationId: string; - userAgent?: UserAgentType; - } - | { - subOrganizationId: string; - userAgent?: UserAgentType; - }; +export type SelectOrganizationParams = { + organizationId: string; + userAgent?: UserAgentType; +}; export const selectOrganization = async (data: SelectOrganizationParams) => { const { data: res } = await apiRequest.post<{ diff --git a/frontend/src/hooks/api/subOrganizations/mutations.tsx b/frontend/src/hooks/api/subOrganizations/mutations.tsx index 81369a62e..f2b9ac7a8 100644 --- a/frontend/src/hooks/api/subOrganizations/mutations.tsx +++ b/frontend/src/hooks/api/subOrganizations/mutations.tsx @@ -11,10 +11,7 @@ export const useCreateSubOrganization = () => { mutationFn: async (dto: TCreateSubOrganizationDTO) => { const { data } = await apiRequest.post<{ organization: TSubOrganization }>( "/api/v1/sub-organizations", - dto, - { - headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header - } + dto ); return data; }, diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index e14a8a01c..2279f4363 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -62,11 +62,7 @@ import { useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; -import { - authKeys, - selectOrganization, - type SelectOrganizationParams -} from "@app/hooks/api/auth/queries"; +import { authKeys, selectOrganization } 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"; @@ -197,26 +193,18 @@ export const Navbar = () => { const handleOrgSelection = async ({ organizationId, - subOrganizationId, navigateTo, onSuccess }: { organizationId?: string; - subOrganizationId?: string; navigateTo?: string; onSuccess?: () => void | Promise; }) => { - if (!organizationId && !subOrganizationId) return; + if (!organizationId) return; - const targetId = subOrganizationId ?? organizationId; + if (organizationId === currentOrg.id) return; - if (targetId === currentOrg.id) return; - - const selectionPayload: SelectOrganizationParams = subOrganizationId - ? { subOrganizationId } - : { organizationId: organizationId as string }; - - const { token, isMfaEnabled, mfaMethod } = await selectOrganization(selectionPayload); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); @@ -225,7 +213,7 @@ export const Navbar = () => { } toggleShowMfa.on(); setMfaSuccessCallback(() => async () => { - await handleOrgSelection({ organizationId, subOrganizationId, onSuccess }); + await handleOrgSelection({ organizationId, onSuccess }); }); return; } @@ -234,11 +222,12 @@ export const Navbar = () => { SecurityClient.setProviderAuthToken(""); queryClient.removeQueries({ queryKey: authKeys.getAuthToken }); queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); - - await router.invalidate(); - await navigateUserToOrg({ navigate, organizationId: targetId, navigateTo }); queryClient.removeQueries({ queryKey: subOrgQuery.queryKey }); + await queryClient.refetchQueries({ queryKey: authKeys.getAuthToken }); + + await navigateUserToOrg({ navigate, organizationId, navigateTo }); + if (onSuccess) { await onSuccess(); } @@ -387,14 +376,17 @@ export const Navbar = () => {