diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index adf9489d4..c25d8d4d1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -148,6 +148,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { diff --git a/backend/src/db/migrations/20250813214709_enforce-google-sso.ts b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts new file mode 100644 index 000000000..2346c91a4 --- /dev/null +++ b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced"; +const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed"; +export async function up(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (!hasGoogleSsoAuthEnforcedColumn) + table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable(); + if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME); + if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME); + }); +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index fb0728707..afc9e2b73 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({ scannerProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), - maxSharedSecretViewLimit: z.number().nullable().optional() + maxSharedSecretViewLimit: z.number().nullable().optional(), + googleSsoAuthEnforced: z.boolean().default(false), + googleSsoAuthLastUsed: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index bd3949a7e..8d2d6fdbe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + enforceGoogleSSO: false, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 098d00feb..345e26638 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -47,6 +47,7 @@ export type TFeatureSet = { auditLogStreamLimit: 3; githubOrgSync: false; samlSSO: false; + enforceGoogleSSO: false; hsm: false; oidcSSO: false; secretAccessInsights: false; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9677c69b1..cdf55127f 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -35,6 +35,7 @@ export interface TPermissionDALFactory { projectFavorites?: string[] | null | undefined; customRoleSlug?: string | null | undefined; orgAuthEnforced?: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; } & { groups: { id: string; @@ -87,6 +88,7 @@ export interface TPermissionDALFactory { }[]; orgId: string; orgAuthEnforced: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; orgRole: OrgMembershipRole; userId: string; projectId: string; @@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), @@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() @@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), @@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId, username, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole, membershipId, groupMembershipId, @@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) => ({ orgId, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole: orgRole as OrgMembershipRole, userId, projectId, diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index d645e2bec..7d61d7499 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], orgRole: OrgMembershipRole ) { @@ -128,10 +129,16 @@ function validateOrgSSO( throw new UnauthorizedError({ name: "No auth method defined" }); } - if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { return; } + // case: google sso is enforced, but the actor is not using google sso + if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) { + throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" }); + } + + // case: SAML SSO is enforced, but the actor is not using SAML SSO if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 85ee82cca..461ceef46 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -146,6 +146,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, membership.orgAuthEnforced, + membership.orgGoogleSsoAuthEnforced, membership.bypassOrgAuthEnabled, membership.role as OrgMembershipRole ); @@ -238,6 +239,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, userProjectPermission.orgAuthEnforced, + userProjectPermission.orgGoogleSsoAuthEnforced, userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.orgRole ); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 323354bc1..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), + googleSsoAuthEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 0aec39f3e..366fa331d 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { try { // @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); const email = profile?.emails?.[0]?.value; if (!email) @@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort + callbackPort, + orgSlug }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { schema: { querystring: z.object({ callback_port: z.string().optional(), + org_slug: z.string().optional(), is_admin_login: z .string() .optional() @@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (orgSlug) { + req.session.set("orgSlug", orgSlug); + } if (isAdminLogin) { req.session.set("isAdminLogin", isAdminLogin); } diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6a3456508..6ef40b3c5 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -448,15 +448,34 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); + + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + const selectedOrg = await orgDAL.findById(organizationId); - if (!hasOrganizationMembership) { + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === 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(selectedOrg.id, { + googleSsoAuthLastUsed: new Date() + }); + } + const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; @@ -502,7 +521,8 @@ export const authLoginServiceFactory = ({ selectedOrg.authEnforced && selectedOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + decodedToken.authMethod !== AuthMethod.GOOGLE ) { await auditLogService.createAuditLog({ orgId: organizationId, @@ -705,7 +725,7 @@ export const authLoginServiceFactory = ({ /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => { // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserByUsername(email); let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; @@ -759,6 +779,8 @@ export const authLoginServiceFactory = ({ const appCfg = getConfig(); + let orgId = ""; + let orgName: undefined | string; if (!user) { // Create a new user based on oAuth if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); @@ -784,7 +806,6 @@ export const authLoginServiceFactory = ({ }); if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { - let orgId = ""; const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); if (!defaultOrg) { throw new BadRequestError({ @@ -824,11 +845,39 @@ export const authLoginServiceFactory = ({ } } + if (!orgId && orgSlug) { + const org = await orgDAL.findOrgBySlug(orgSlug); + + if (org) { + // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org + const orgMembership = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, + [`${TableName.OrgMembership}.isActive` as "isActive"]: true, + [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + }); + + if (orgMembership) { + orgId = org.id; + orgName = org.name; + } + } + } + const isUserCompleted = user.isAccepted; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + + ...(orgId && orgSlug && orgName !== undefined + ? { + organizationId: orgId, + organizationName: orgName, + organizationSlug: orgSlug + } + : {}), + username: user.username, email: user.email, isEmailVerified: user.isEmailVerified, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index d9d9520a8..09d81033f 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -32,6 +32,7 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; + orgSlug?: string; }; export type TOauthTokenExchangeDTO = { diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ae82cd1bc..4a3bdb06e 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ createdAt: true, updatedAt: true, authEnforced: true, + googleSsoAuthEnforced: true, scimEnabled: true, kmsDefaultKeyId: true, defaultMembershipRole: true, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index ba76d0a93..356a8451c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -364,6 +364,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -430,6 +431,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced !== undefined) { + if (!plan.enforceGoogleSSO) { + throw new BadRequestError({ + message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO." + }); + } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (authEnforced && googleSsoAuthEnforced) { + throw new BadRequestError({ + message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time." + }); + } + if (authEnforced) { const samlCfg = await samlConfigDAL.findOne({ orgId, @@ -460,6 +476,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced) { + if (googleSsoAuthEnforced && currentOrg.authEnforced) { + throw new BadRequestError({ + message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled." + }); + } + + if (!currentOrg.googleSsoAuthLastUsed) { + throw new BadRequestError({ + message: + "Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization." + }); + } + } + let defaultMembershipRole: string | undefined; if (defaultMembershipRoleSlug) { defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ @@ -474,6 +505,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 645692145..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { name: string; slug: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index cd620bb64..15e1b861c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -104,6 +104,7 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, orgId, @@ -125,6 +126,7 @@ export const useUpdateOrg = () => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, defaultMembershipRoleSlug, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 73267f0f6..71f2a8b90 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; @@ -34,6 +35,7 @@ export type UpdateOrgDTO = { orgId: string; name?: string; authEnforced?: boolean; + googleSsoAuthEnforced?: boolean; scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 4ded71cfe..338599fe0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -48,6 +48,7 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + enforceGoogleSSO: boolean; projectTemplates: boolean; kmip: boolean; secretScanning: boolean; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..5c37d4404 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -240,6 +240,13 @@ export const Navbar = () => { return; } + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + handleOrgChange(org?.id); }} variant="plain" diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index f331e4bda..9e6850f82 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => { } } - if (organization.authEnforced && !canBypassOrgAuth) { + if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) { + const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod }; + // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO - await logout.mutateAsync(); let url = ""; if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; - } else { + } else if (organization.orgAuthMethod === AuthMethod.SAML) { url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; if (callbackPort) { url += `?callback_port=${callbackPort}`; } + } else if ( + organization.googleSsoAuthEnforced && + authToken.authMethod !== AuthMethod.GOOGLE + ) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } } - window.location.href = url; - return; + // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO + // see line 103-106 + if (url) { + await logout.mutateAsync(); + window.location.href = url; + return; + } } const { token, isMfaEnabled, mfaMethod } = await selectOrg diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index ac8685192..de568931a 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -13,8 +13,23 @@ import { } from "@app/context"; import { useLogoutUser, useUpdateOrg } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { twMerge } from "tailwind-merge"; -export const OrgGeneralAuthSection = () => { +enum EnforceAuthType { + SAML = "saml", + GOOGLE = "google", + OIDC = "oidc" +} + +export const OrgGeneralAuthSection = ({ + isSamlConfigured, + isOidcConfigured, + isGoogleConfigured +}: { + isSamlConfigured: boolean; + isOidcConfigured: boolean; + isGoogleConfigured: boolean; +}) => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); @@ -23,27 +38,61 @@ export const OrgGeneralAuthSection = () => { const logout = useLogoutUser(); - const handleEnforceOrgAuthToggle = async (value: boolean) => { + const handleEnforceOrgAuthToggle = async (value: boolean, type: EnforceAuthType) => { try { if (!currentOrg?.id) return; - if (!subscription?.samlSSO) { - handlePopUpOpen("upgradePlan"); - return; + + if (type === EnforceAuthType.SAML) { + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else if (type === EnforceAuthType.GOOGLE) { + if (!subscription?.enforceGoogleSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + googleSsoAuthEnforced: value + }); + } else if (type === EnforceAuthType.OIDC) { + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else { + createNotification({ + text: `Invalid auth enforcement type ${type}`, + type: "error" + }); } - await mutateAsync({ - orgId: currentOrg?.id, - authEnforced: value - }); - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, + text: `Successfully ${value ? "enabled" : "disabled"} org-level auth`, type: "success" }); if (value) { await logout.mutateAsync(); - window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + + if (type === EnforceAuthType.SAML) { + window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + } else if (type === EnforceAuthType.GOOGLE) { + window.open(`/api/v1/sso/redirect/google?org_slug=${currentOrg.slug}`); + } + window.close(); } } catch (err) { @@ -78,45 +127,91 @@ export const OrgGeneralAuthSection = () => { }; return ( - <> - {/*
-
-

Allow users to send invites

- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

Allow members to invite new users to this organization

-
*/} -
-
-
- Enforce SAML SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via SAML to access this organization +

+
+

SSO Enforcement

+

+ Manage strict enforcement of specific authentication methods for your organization.

- {currentOrg?.authEnforced && ( -
+
+
+
+
+ Enforce SAML SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.SAML) + } + isChecked={currentOrg?.authEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.googleSsoAuthEnforced} + /> + )} + +
+

+ Enforce users to authenticate via SAML to access this organization. +
+ When this is enabled your organization members will only be able to login with SAML. +

+
+ +
+
+
+ Enforce OIDC SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.OIDC) + } + isDisabled={!isAllowed} + /> + )} + +
+

+ Enforce users to authenticate via OIDC to access this organization. +
+ When this is enabled your organization members will only be able to login with OIDC. +

+
+ +
+
+
+ Enforce Google SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) + } + isChecked={currentOrg?.googleSsoAuthEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.authEnforced} + /> + )} + +
+

+ Enforce users to authenticate via Google to access this organization. +
+ When this is enabled your organization members will only be able to login with Google. +

+
+
+ {(currentOrg?.authEnforced || currentOrg?.googleSsoAuthEnforced) && ( +
Enable Admin SSO Bypass @@ -125,8 +220,8 @@ export const OrgGeneralAuthSection = () => { content={
- When this is enabled, we strongly recommend enforcing MFA at the organization - level. + When enabling admin SSO bypass, we highly recommend enabling MFA enforcement + at the organization-level for security reasons.

In case of a lockout, admins can use the{" "} @@ -182,6 +277,6 @@ export const OrgGeneralAuthSection = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can enforce SAML SSO if you switch to Infisical's Pro plan." /> - +

); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index 9c7293210..e66987bae 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -95,43 +95,25 @@ export const OrgLDAPSection = (): JSX.Element => { }; return ( -
+
-
-

LDAP

-
- - {(isAllowed) => ( - - )} - +
+
+

LDAP

+

Manage LDAP authentication configuration

-
-

Manage LDAP authentication configuration

-
-
-
-

LDAP Group Mappings

{(isAllowed) => ( - )}
-

- Manage how LDAP groups are mapped to internal groups in Infisical -

+ {data && ( -
+

Enable LDAP

@@ -152,6 +134,27 @@ export const OrgLDAPSection = (): JSX.Element => {

)} + +
+
+

LDAP Group Mappings

+ + {(isAllowed) => ( + + )} + +
+

+ Manage how LDAP groups are mapped to internal groups in Infisical +

+
+ { const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); - const { mutateAsync: updateOrg } = useUpdateOrg(); - const logout = useLogoutUser(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addOIDC", "upgradePlan" @@ -54,56 +52,6 @@ export const OrgOIDCSection = (): JSX.Element => { } }; - const handleEnforceOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - authEnforced: value - }); - - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, - type: "success" - }); - - if (value) { - await logout.mutateAsync(); - window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`); - window.close(); - } - } catch (err) { - console.error(err); - } - }; - - const handleEnableBypassOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - bypassOrgAuthEnabled: value - }); - - createNotification({ - text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, - type: "success" - }); - } catch (err) { - console.error(err); - } - }; - const handleOIDCGroupManagement = async (value: boolean) => { try { if (!currentOrg?.id) return; @@ -136,25 +84,22 @@ export const OrgOIDCSection = (): JSX.Element => { }; return ( -
-
-
-

OIDC

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

OIDC

+

Manage OIDC authentication configuration

-

Manage OIDC authentication configuration

+ + {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
{data && (
@@ -178,88 +123,6 @@ export const OrgOIDCSection = (): JSX.Element => {

)} -
-
-
- Enforce OIDC SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via OIDC to access this organization. -

-
- {currentOrg?.authEnforced && ( -
-
-
- Enable Admin SSO Bypass - - - When this is enabled, we strongly recommend enforcing MFA at the organization - level. - -

- In case of a lockout, admins can use the{" "} - - Admin Login Portal - {" "} - at{" "} - - {window.location.origin}/login/admin - -

-
- } - > - - -
- - {(isAllowed) => ( - handleEnableBypassOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- - Allow organization admins to bypass OIDC enforcement when SSO is unavailable, - misconfigured, or inaccessible. - -

-
- )}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 3ffd8b3c2..33843f50f 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -79,25 +79,24 @@ export const OrgSSOSection = (): JSX.Element => { }; return ( - <> -
-
-
-

SAML

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

SAML

+

Manage SAML authentication configuration

-

Manage SAML authentication configuration

+ {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
-
-
+
+

Enable SAML

{!isPending && ( @@ -126,6 +125,6 @@ export const OrgSSOSection = (): JSX.Element => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can use SAML SSO if you switch to Infisical's Pro plan." /> - +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 9b11c1302..9964fdf4d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -49,13 +49,19 @@ export const OrgSsoTab = withPermission( ); const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig; - const shouldDisplaySection = (method: LoginMethod) => - !enabledLoginMethods || enabledLoginMethods.includes(method); + const shouldDisplaySection = (method: LoginMethod[] | LoginMethod) => { + if (Array.isArray(method)) { + return method.some((m) => !enabledLoginMethods || enabledLoginMethods.includes(m)); + } - const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer); + return !enabledLoginMethods || enabledLoginMethods.includes(method); + }; + + const isOidcConfigured = Boolean(oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer)); const isSamlConfigured = samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert); const isLdapConfigured = ldapConfig && ldapConfig.url; + const isGoogleConfigured = shouldDisplaySection(LoginMethod.GOOGLE); const shouldShowCreateIdentityProviderView = !isOidcConfigured && !isSamlConfigured && !isLdapConfigured; @@ -65,11 +71,14 @@ export const OrgSsoTab = withPermission( shouldDisplaySection(LoginMethod.OIDC) || shouldDisplaySection(LoginMethod.LDAP) ? ( <> -
-

Connect an Identity Provider

-

- Connect your identity provider to simplify user management -

+
+
+

Connect an Identity Provider

+

+ Connect your identity provider to simplify user management with options like SAML, + OIDC, and LDAP. +

+
{shouldDisplaySection(LoginMethod.SAML) && (
- {shouldShowCreateIdentityProviderView ? ( - createIdentityProviderView - ) : ( - <> - {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && ( -
- - +
+ {shouldDisplaySection([LoginMethod.SAML, LoginMethod.GOOGLE]) && ( + + )} + + {shouldShowCreateIdentityProviderView ? ( + createIdentityProviderView + ) : ( +
+
+ {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && } + {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } + {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && }
- )} - {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } - {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && } - - )} +
+ )} +
handlePopUpToggle("upgradePlan", isOpen)}