From 767c594ad8c03bfcae50aa9e54bc1e3e7cfece0f Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:34:59 -0600 Subject: [PATCH] Updating UI for Okta config (#35204) **Related issue:** Resolves #34539 Figma: https://www.figma.com/design/OgQ8SyLK8Sw5thXtF1eiNP/-31909-Conditional-access-w--Okta Requires backend PR https://github.com/fleetdm/fleet/pull/35526 to view Apple profile. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added Okta as a conditional access provider alongside Microsoft Entra * Users can now configure both identity providers simultaneously or use either independently * Updated configuration interface with new Okta-specific settings * Redesigned UI with separate provider cards for improved clarity --------- Co-authored-by: Jacob Shandling --- .storybook/main.ts | 1 + changes/31909-okta-conditional-access | 3 +- frontend/__mocks__/configMock.ts | 4 + frontend/interfaces/config.ts | 6 + .../IntegrationsPage/IntegrationNavItems.tsx | 14 +- .../IntegrationsPage/IntegrationsPage.tsx | 8 +- .../ConditionalAccess.stories.tsx | 208 +++++++- .../ConditionalAccess.tests.tsx | 346 ++++++++++++- .../ConditionalAccess/ConditionalAccess.tsx | 480 ++++++++++-------- .../cards/ConditionalAccess/_styles.scss | 10 + .../EntraConditionalAccessModal.tsx | 135 +++++ .../EntraConditionalAccessModal/index.ts | 1 + .../OktaConditionalAccessModal.tsx | 392 ++++++++++++++ .../OktaConditionalAccessModal/_styles.scss | 28 + .../OktaConditionalAccessModal/index.ts | 1 + .../services/entities/conditional_access.ts | 8 + frontend/utilities/endpoints.ts | 2 + 17 files changed, 1392 insertions(+), 255 deletions(-) create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/index.ts create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/_styles.scss create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/index.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index df6fa0b5dd..af7b0f60dd 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -46,6 +46,7 @@ const config: StorybookConfig = { "../frontend/components/**/*.stories.mdx", "../frontend/components/**/*.stories.@(js|jsx|ts|tsx)", "../frontend/pages/SoftwarePage/components/**/*.stories.@(js|jsx|ts|tsx)", + "../frontend/pages/admin/IntegrationsPage/**/*.stories.@(js|jsx|ts|tsx)", ], addons: [ "@storybook/addon-links", diff --git a/changes/31909-okta-conditional-access b/changes/31909-okta-conditional-access index fc506fbde6..f4231bf671 100644 --- a/changes/31909-okta-conditional-access +++ b/changes/31909-okta-conditional-access @@ -1 +1,2 @@ -Added integration for Okta conditional access, where Fleet acts as a factor and blocks end users from logging into third-party apps, via Okta, if they are failing specific policies. +- Added integration for Okta conditional access, where Fleet acts as a factor and blocks end users from logging into third-party apps, via Okta, if they are failing specific policies. +- Added Okta conditional access configuration to the Fleet UI under Settings -> Integrations -> Conditional access. diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index c3d321ba08..bad74ad3e1 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -115,6 +115,10 @@ const DEFAULT_CONFIG_MOCK: IConfig = { conditional_access: { microsoft_entra_tenant_id: "123", microsoft_entra_connection_configured: true, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", }, host_expiry_settings: { host_expiry_enabled: false, diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index f5c2660d53..6593e8d1b5 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -156,8 +156,14 @@ export interface IConfig { // configuration details for conditional access. For enabled/disabled status per team, see // subfields under `integrations` conditional_access?: { + // Microsoft Entra microsoft_entra_tenant_id: string; microsoft_entra_connection_configured: boolean; + // Okta + okta_idp_id: string; + okta_assertion_consumer_service_url: string; + okta_audience_uri: string; + okta_certificate: string; }; host_expiry_settings: { host_expiry_enabled: boolean; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index 5a2d65c347..9e3aee6ec7 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -11,9 +11,7 @@ import IdentityProviders from "./cards/IdentityProviders"; import Sso from "./cards/Sso"; import GlobalHostStatusWebhook from "../IntegrationsPage/cards/GlobalHostStatusWebhook"; -const getIntegrationSettingsNavItems = ( - isManagedCloud: boolean -): ISideNavItem[] => { +const getIntegrationSettingsNavItems = (): ISideNavItem[] => { const items: ISideNavItem[] = [ { title: "Ticket destinations", @@ -63,16 +61,14 @@ const getIntegrationSettingsNavItems = ( path: PATHS.ADMIN_INTEGRATIONS_HOST_STATUS_WEBHOOK, Card: GlobalHostStatusWebhook, }, - ]; - - if (isManagedCloud) { - items.push({ + { title: "Conditional access", urlSection: "conditional-access", path: PATHS.ADMIN_INTEGRATIONS_CONDITIONAL_ACCESS, Card: ConditionalAccess, - }); - } + }, + ]; + return items; }; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx index 0d9c3bdb9e..5d4070cb19 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx @@ -7,8 +7,6 @@ import deepDifference from "utilities/deep_difference"; import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; -import paths from "router/paths"; - import configAPI from "services/entities/config"; import { IConfig } from "interfaces/config"; @@ -90,12 +88,8 @@ const IntegrationsPage = ({ ); if (!appConfig) return <>; - const isManagedCloud = appConfig.license.managed_cloud; - if (section?.includes("conditional-access") && !isManagedCloud) { - router.push(paths.ADMIN_SETTINGS); - } - const navItems = getIntegrationSettingsNavItems(isManagedCloud); + const navItems = getIntegrationSettingsNavItems(); const DEFAULT_SETTINGS_SECTION = navItems[0]; const currentSection = navItems.find((item) => item.urlSection === section) ?? diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx index bd58c82f59..42516a6688 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx @@ -1,7 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; import { Meta, StoryObj } from "@storybook/react"; +import { + QueryClient, + QueryClientProvider, + QueryClientProviderProps, +} from "react-query"; + +import createMockConfig from "__mocks__/configMock"; +import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; import ConditionalAccess from "./ConditionalAccess"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Workaround for React Query v3 with React 18 - explicitly add children prop +// See frontend/router/index.tsx for the same pattern +type CustomQueryClientProviderProps = React.PropsWithChildren; +const CustomQueryClientProvider: React.FC = QueryClientProvider; + +const mockNotificationContext = { + renderFlash: () => { + // Mock function for stories + }, + hideFlash: () => { + // Mock function for stories + }, +}; + const meta: Meta = { title: "Components/ConditionalAccess", component: ConditionalAccess, @@ -11,4 +44,177 @@ export default meta; type Story = StoryObj; -export const Basic: Story = {}; +export const NotConfigured: Story = { + name: "Not configured (premium tier)", + decorators: [ + (Story) => { + const appContextValue = { + isPremiumTier: true, + config: createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", + }, + }), + setConfig: () => { + // Mock function for stories + }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export const EntraConfigured: Story = { + name: "Microsoft Entra configured", + decorators: [ + (Story) => { + const appContextValue = { + isPremiumTier: true, + config: createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "abcd-1234-efgh-5678", + microsoft_entra_connection_configured: true, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", + }, + }), + setConfig: () => { + // Mock function for stories + }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export const OktaConfigured: Story = { + name: "Okta configured", + decorators: [ + (Story) => { + const appContextValue = { + isPremiumTier: true, + config: createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "okta-idp-123456", + okta_assertion_consumer_service_url: + "https://fleet.example.com/api/v1/saml/acs", + okta_audience_uri: "https://fleet.example.com", + okta_certificate: + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + }, + }), + setConfig: () => { + // Mock function for stories + }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export const BothConfigured: Story = { + name: "Both providers configured", + decorators: [ + (Story) => { + const appContextValue = { + isPremiumTier: true, + config: createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "abcd-1234-efgh-5678", + microsoft_entra_connection_configured: true, + okta_idp_id: "okta-idp-123456", + okta_assertion_consumer_service_url: + "https://fleet.example.com/api/v1/saml/acs", + okta_audience_uri: "https://fleet.example.com", + okta_certificate: + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + }, + }), + setConfig: () => { + // Mock function for stories + }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export const FreeTier: Story = { + name: "Free tier (premium feature)", + decorators: [ + (Story) => { + const appContextValue = { + isPremiumTier: false, + config: createMockConfig({}), + setConfig: () => { + // Mock function for stories + }, + }; + + return ( + + + + + + + + ); + }, + ], +}; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx index 864a7c688e..56f453a9dc 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tests.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import createMockConfig from "__mocks__/configMock"; @@ -18,25 +18,128 @@ const triggerConditionalAccessHandler = http.post( } ); +const updateConfigHandler = http.patch(baseUrl("/config"), () => { + return HttpResponse.json( + createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "okta-idp-123", + okta_assertion_consumer_service_url: "https://example.com/acs", + okta_audience_uri: "https://example.com", + okta_certificate: "cert-data", + }, + }) + ); +}); + +// Helper to create a config with empty conditional access settings +const createEmptyConditionalAccessConfig = () => + createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", + }, + }); + describe("Conditional access", () => { describe("Not configured", () => { - it("Renders the empty form when no tenant id is saved", () => { + it("Renders both integration cards when nothing is configured", () => { + const mockConfig = createEmptyConditionalAccessConfig(); + const render = createCustomRenderer({ withBackendMock: true, context: { app: { isPremiumTier: true, + config: mockConfig, }, }, }); render(); - expect(screen.getByText("Microsoft Entra tenant ID")).toBeInTheDocument(); - expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.getByText("Okta")).toBeInTheDocument(); + expect( + screen.getByText("Connect Okta to enable conditional access.") + ).toBeInTheDocument(); + expect(screen.getByText("Microsoft Entra")).toBeInTheDocument(); + expect( + screen.getByText("Connect Entra to enable conditional access.") + ).toBeInTheDocument(); + // Should have two Connect buttons + expect(screen.getAllByText("Connect")).toHaveLength(2); }); - it("Renders the 'continue in new tab' screen when the form is submitted", async () => { + + it("Opens the Entra modal when clicking Connect on Entra card", async () => { + const mockConfig = createEmptyConditionalAccessConfig(); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + const { user } = render(); + + // Click the second Connect button (Microsoft Entra) + const connectButtons = screen.getAllByText("Connect"); + await user.click(connectButtons[1]); + + // Modal should open + expect( + screen.getByText("Microsoft Entra conditional access") + ).toBeInTheDocument(); + expect(screen.getByText("Microsoft Entra tenant ID")).toBeInTheDocument(); + }); + + it("Triggers Microsoft auth flow when submitting Entra modal", async () => { + const mockConfig = createEmptyConditionalAccessConfig(); + mockServer.use(triggerConditionalAccessHandler); + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + const { user } = render(); + + // Open modal + const connectButtons = screen.getAllByText("Connect"); + await user.click(connectButtons[1]); + + // Fill in tenant ID + const input = screen.getByRole("textbox"); + await user.type(input, "abcdefg"); + + // Submit form + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + // Should show the "continue in new tab" message + await waitFor(() => { + expect( + screen.getByText( + /To complete your integration, follow the instructions in the other tab/ + ) + ).toBeInTheDocument(); + }); + }); + + it("Opens the Okta modal when clicking Connect on Okta card", async () => { const render = createCustomRenderer({ withBackendMock: true, context: { @@ -48,23 +151,102 @@ describe("Conditional access", () => { const { user } = render(); - const input = screen.getByRole("textbox"); - await user.type(input, "abcdefg"); - await user.click(screen.getByRole("button")); + // Click the first Connect button (Okta) + const connectButtons = screen.getAllByText("Connect"); + await user.click(connectButtons[0]); + // Modal should open with new Figma structure + expect(screen.getByText("Okta conditional access")).toBeInTheDocument(); + // Check for new sections expect( - screen.getByText( - "To complete your integration, follow the instructions in the other tab, then refresh this page to verify." - ) + screen.getByText("Identity provider (IdP) signature certificate") ).toBeInTheDocument(); + expect(screen.getByText("User scope profile")).toBeInTheDocument(); + // Check for input fields + expect(screen.getByText("IdP ID")).toBeInTheDocument(); + expect( + screen.getByText("Assertion consumer service URL") + ).toBeInTheDocument(); + expect(screen.getByText("Audience URI")).toBeInTheDocument(); + // Check for certificate upload section + expect(screen.getByText("Okta certificate")).toBeInTheDocument(); + }); + + it("Saves Okta configuration when submitting form", async () => { + const mockConfig = createEmptyConditionalAccessConfig(); + + mockServer.use(updateConfigHandler); + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + const { user } = render(); + + // Open modal + const connectButtons = screen.getAllByText("Connect"); + await user.click(connectButtons[0]); + + // Wait for modal to open + await waitFor(() => { + expect(screen.getByText("Okta conditional access")).toBeInTheDocument(); + }); + + // Fill in text fields + // Note: First textarea is the read-only User scope profile + const textboxes = screen.getAllByRole("textbox"); + await user.type(textboxes[1], "okta-idp-123"); // IdP ID + await user.type(textboxes[2], "https://example.com/acs"); // ACS URL + await user.type(textboxes[3], "https://example.com"); // Audience URI + + // Upload certificate file + const certificateContent = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKm7MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +-----END CERTIFICATE-----`; + + const file = new File([certificateContent], "certificate.pem", { + type: "application/x-pem-file", + }); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + await user.upload(fileInput, file); + + // Wait for file to be processed + await waitFor(() => { + expect(screen.getByText("certificate.pem")).toBeInTheDocument(); + }); + + // Submit form + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + + // Should show success message and close modal + await waitFor(() => { + expect( + screen.queryByText("Okta conditional access") + ).not.toBeInTheDocument(); + }); }); }); + describe("Confirming configured", () => { - it("Renders a spinner when tenant id is present but configuation not yet confirmed", () => { + it("Renders a spinner when Entra tenant id is present but configuration not yet confirmed", () => { const mockConfig = createMockConfig({ conditional_access: { microsoft_entra_tenant_id: "abcdefg", microsoft_entra_connection_configured: false, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", }, }); @@ -83,12 +265,17 @@ describe("Conditional access", () => { expect(screen.getByTestId("spinner")).toBeVisible(); }); }); + describe("Configured", () => { - it("Renders the 'configured' screen when tenant id is present and configuration is confirmed", async () => { + it("Shows Entra as configured when connection is confirmed", async () => { const mockConfig = createMockConfig({ conditional_access: { microsoft_entra_tenant_id: "abcdefg", microsoft_entra_connection_configured: true, + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", }, }); @@ -105,9 +292,140 @@ describe("Conditional access", () => { render(); expect( - screen.getByText("Microsoft Entra tenant ID:") + screen.getByText("Microsoft Entra conditional access configured") ).toBeInTheDocument(); + // Should only have Delete button for Entra (no Edit button per Figma design) expect(screen.getByText("Delete")).toBeInTheDocument(); + expect(screen.queryByText("Edit")).not.toBeInTheDocument(); + }); + + it("Shows Okta as configured when all Okta fields are present", async () => { + const mockConfig = createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "okta-idp-123", + okta_assertion_consumer_service_url: "https://example.com/acs", + okta_audience_uri: "https://example.com", + okta_certificate: "cert-data", + }, + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + render(); + + expect( + screen.getByText("Okta conditional access configured") + ).toBeInTheDocument(); + }); + + it("Shows both providers as configured when both are set up", async () => { + const mockConfig = createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "abcdefg", + microsoft_entra_connection_configured: true, + okta_idp_id: "okta-idp-123", + okta_assertion_consumer_service_url: "https://example.com/acs", + okta_audience_uri: "https://example.com", + okta_certificate: "cert-data", + }, + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + render(); + + expect( + screen.getByText("Okta conditional access configured") + ).toBeInTheDocument(); + expect( + screen.getByText("Microsoft Entra conditional access configured") + ).toBeInTheDocument(); + }); + + it("Shows delete confirmation modal when clicking Delete on Okta", async () => { + const mockConfig = createMockConfig({ + conditional_access: { + microsoft_entra_tenant_id: "", + microsoft_entra_connection_configured: false, + okta_idp_id: "okta-idp-123", + okta_assertion_consumer_service_url: "https://example.com/acs", + okta_audience_uri: "https://example.com", + okta_certificate: "cert-data", + }, + }); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: true, + config: mockConfig, + }, + }, + }); + + const { user } = render(); + + // Should show configured state + expect( + screen.getByText("Okta conditional access configured") + ).toBeInTheDocument(); + + // Click Delete button (first one is for Okta) + const deleteButton = screen.getAllByText("Delete")[0]; + await user.click(deleteButton); + + // Should show delete confirmation modal + await waitFor(() => { + expect( + screen.getByText(/Fleet will be disconnected from Okta/) + ).toBeInTheDocument(); + }); + + // Modal should have Delete and Cancel buttons + expect( + screen.getAllByRole("button", { name: "Delete" }).length + ).toBeGreaterThan(0); + expect( + screen.getByRole("button", { name: "Cancel" }) + ).toBeInTheDocument(); + }); + }); + + describe("Premium tier", () => { + it("Shows premium feature message when not premium tier", () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + isPremiumTier: false, + }, + }, + }); + + render(); + + expect( + screen.getByText(/This feature is included in Fleet Premium/i) + ).toBeInTheDocument(); }); }); }); diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx index b4f3cbe14a..963944cc6e 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx @@ -1,7 +1,5 @@ import React, { useContext, useEffect, useState } from "react"; -import { size } from "lodash"; - import paths from "router/paths"; import { NotificationContext } from "context/notification"; @@ -11,66 +9,87 @@ import conditionalAccessAPI, { } from "services/entities/conditional_access"; import configAPI from "services/entities/config"; -// @ts-ignore -import InputField from "components/forms/fields/InputField"; import CustomLink from "components/CustomLink"; import SectionHeader from "components/SectionHeader"; +import Icon from "components/Icon"; +import { IconNames } from "components/icons"; import { DEFAULT_USE_QUERY_OPTIONS, LEARN_MORE_ABOUT_BASE_LINK, } from "utilities/constants"; import Button from "components/buttons/Button"; -import { IInputFieldParseTarget } from "interfaces/form_field"; import { AppContext } from "context/app"; import Spinner from "components/Spinner"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; -import InfoBanner from "components/InfoBanner"; -import Icon from "components/Icon"; -import TooltipTruncatedText from "components/TooltipTruncatedText"; import { useQuery } from "react-query"; import DataError from "components/DataError"; import Modal from "components/Modal"; import { IConfig } from "interfaces/config"; -const baseClass = "conditional-access"; +import SectionCard from "../MdmSettings/components/SectionCard"; +import EntraConditionalAccessModal from "./components/EntraConditionalAccessModal"; +import OktaConditionalAccessModal from "./components/OktaConditionalAccessModal"; -const MSETID = "microsoft_entra_tenant_id"; +const baseClass = "conditional-access"; interface IDeleteConditionalAccessModal { toggleDeleteConditionalAccessModal: () => void; - onDelete: () => void; + onDelete: (config: IConfig) => void; + provider: "microsoft-entra" | "okta"; + config: IConfig | null; } const DeleteConditionalAccessModal = ({ toggleDeleteConditionalAccessModal, onDelete, + provider, + config, }: IDeleteConditionalAccessModal) => { const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); + const providerName = + provider === "microsoft-entra" ? "Microsoft Entra" : "Okta"; + const handleDelete = async () => { setIsDeleting(true); try { - await conditionalAccessAPI.deleteMicrosoftConditionalAccess(); - renderFlash("success", "Successfully disconnected from Microsoft Entra."); + let updatedConfig; + if (provider === "microsoft-entra") { + await conditionalAccessAPI.deleteMicrosoftConditionalAccess(); + updatedConfig = await configAPI.loadAll(); + } else { + // For Okta, clear all fields via config API + updatedConfig = await configAPI.update({ + conditional_access: { + okta_idp_id: "", + okta_assertion_consumer_service_url: "", + okta_audience_uri: "", + okta_certificate: "", + // Preserve existing Microsoft Entra settings + microsoft_entra_tenant_id: + config?.conditional_access?.microsoft_entra_tenant_id || "", + microsoft_entra_connection_configured: + config?.conditional_access + ?.microsoft_entra_connection_configured || false, + }, + }); + } + renderFlash("success", `Successfully disconnected from ${providerName}.`); toggleDeleteConditionalAccessModal(); - onDelete(); + onDelete(updatedConfig); } catch { renderFlash( "error", - "Could not disconnect from Microsoft Entra, please try again." + `Could not disconnect from ${providerName}, please try again.` ); } setIsDeleting(false); }; - return ( - + const copy = + provider === "microsoft-entra" ? ( <>

Before you delete, first unblock all end users.{" "} @@ -84,6 +103,22 @@ const DeleteConditionalAccessModal = ({ If you don't, end users will stay blocked even after deleting Entra.

+ + ) : ( +

+ Fleet will be disconnected from Okta and will stop blocking end users + from logging in with single sign-on. +

+ ); + + return ( + + <> + {copy}
+ ) : ( + + ) + } + > + {oktaConfigured + ? "Okta conditional access configured" + : "Connect Okta to enable conditional access."} + + ); + }; + + const renderEntraContent = () => { + if (entraPhase === EntraPhase.ConfirmingConfigured) { + return ( + + + ); - setIsUpdating(false); - setPhase(Phase.FormSubmitted); - window.open(msAuthURL); - } catch (e) { - renderFlash( - "error", - "Could not update conditional access integration settings." - ); - setIsUpdating(false); } - }; - const onDeleteConditionalAccess = async () => { - setFormData({ [MSETID]: "" }); - refetchConfig(); - }; + if (entraPhase === EntraPhase.ConfirmationError) { + return ( + + + + ); + } - const onInputChange = ({ name, value }: IInputFieldParseTarget) => { - const newFormData = { ...formData, [name]: value }; - setFormData(newFormData); - const newErrs = validate(newFormData); - // only set errors that are updates of existing errors - // new errors are only set onBlur or submit - const errsToSet: Record = {}; - Object.keys(formErrors).forEach((k) => { - // @ts-ignore - if (newErrs[k]) { - // @ts-ignore - errsToSet[k] = newErrs[k]; - } - }); - setFormErrors(errsToSet); - }; + // Compute Entra card props to avoid nested ternaries + const entraIsConfigured = entraPhase === EntraPhase.Configured; + const entraIsAwaitingOAuth = entraPhase === EntraPhase.AwaitingOAuth; - const onInputBlur = () => { - setFormErrors(validate(formData)); + let entraIconName: IconNames | undefined; + if (entraIsConfigured) { + entraIconName = "success"; + } else if (entraIsAwaitingOAuth) { + entraIconName = "pending-outline"; + } + + let entraCta: React.JSX.Element | undefined; + if (entraIsConfigured) { + entraCta = ( + + ); + } else if (!entraIsAwaitingOAuth) { + entraCta = ; + } + + let entraContent: string; + if (entraIsConfigured) { + entraContent = "Microsoft Entra conditional access configured"; + } else if (entraIsAwaitingOAuth) { + entraContent = + "To complete your integration, follow the instructions in the other tab, then refresh this page to verify."; + } else { + entraContent = "Connect Entra to enable conditional access."; + } + + return ( + + {entraContent} + + ); }; const renderContent = () => { - switch (phase) { - case Phase.Form: - return ( -
- - You can find this in your Microsoft Entra admin center.{" "} - - - } - onChange={onInputChange} - name={MSETID} - value={formData[MSETID]} - parseTarget - onBlur={onInputBlur} - error={formErrors[MSETID]} - /> - - - ); - case Phase.FormSubmitted: - return ( - - To complete your integration, follow the instructions in the other - tab, then refresh this page to verify. - - ); - case Phase.ConfirmingConfigured: - // checking integration - return ; - case Phase.ConfirmationError: - return ; - case Phase.Configured: - return ( - -
- - Microsoft Entra tenant ID:{" "} - -
- -
- ); - default: - return ; - } + return ( +
+ {renderOktaContent()} + {isManagedCloud && renderEntraContent()} +
+ ); }; return (

- Block hosts failing any policies from logging in with single sign-on. - Enable or disable on the{" "} + Block hosts failing policies from logging in with single sign-on. Once + connected, enable or disable on the{" "} page.

{renderContent()} - {showDeleteConditionalAccessModal && ( + {showEntraModal && ( + + )} + {showOktaModal && ( + + )} + {providerToDelete && ( )}
diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/_styles.scss index d558042fde..b93be00c3f 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/_styles.scss +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/_styles.scss @@ -1,4 +1,14 @@ .conditional-access { + &__page-description { + margin-bottom: $pad-large; + } + + &__cards { + display: flex; + flex-direction: column; + gap: $pad-large; + } + &__success { .info-banner__info { display: flex; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx new file mode 100644 index 0000000000..4df47a3ffc --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx @@ -0,0 +1,135 @@ +import React, { useContext, useState } from "react"; +import { size } from "lodash"; + +import { NotificationContext } from "context/notification"; +import conditionalAccessAPI from "services/entities/conditional_access"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import CustomLink from "components/CustomLink"; +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import { IInputFieldParseTarget } from "interfaces/form_field"; +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; + +const baseClass = "entra-conditional-access-modal"; + +const MSETID = "microsoft_entra_tenant_id"; + +interface IFormData { + [MSETID]: string; +} + +interface IFormErrors { + [MSETID]?: string | null; +} + +const validate = (formData: IFormData) => { + const errs: IFormErrors = {}; + if (!formData[MSETID]) { + errs[MSETID] = "Tenant ID must be present"; + } + return errs; +}; + +export interface IEntraConditionalAccessModalProps { + onCancel: () => void; + onSuccess: () => void; +} + +const EntraConditionalAccessModal = ({ + onCancel, + onSuccess, +}: IEntraConditionalAccessModalProps) => { + const { renderFlash } = useContext(NotificationContext); + + const [isUpdating, setIsUpdating] = useState(false); + const [formData, setFormData] = useState({ + [MSETID]: "", + }); + const [formErrors, setFormErrors] = useState({}); + + const onSubmit = async (evt: React.FormEvent) => { + evt.preventDefault(); + + const errs = validate(formData); + if (Object.keys(errs).length > 0) { + setFormErrors(errs); + return; + } + setIsUpdating(true); + try { + const { + microsoft_authentication_url: msAuthURL, + } = await conditionalAccessAPI.triggerMicrosoftConditionalAccess( + formData[MSETID] + ); + window.open(msAuthURL); + setIsUpdating(false); + // Close modal and show banner on main page + onSuccess(); + } catch (e) { + renderFlash( + "error", + "Could not update conditional access integration settings." + ); + setIsUpdating(false); + } + }; + + const onInputChange = ({ name, value }: IInputFieldParseTarget) => { + setFormData({ ...formData, [name]: value }); + setFormErrors({}); // Clear any existing error + }; + + const onInputBlur = () => { + setFormErrors(validate(formData)); + }; + + return ( + + <> +
+

+ To configure Microsoft Entra conditional access, follow the + instructions in the{" "} + +

+ +
+ + +
+ + +
+ ); +}; + +export default EntraConditionalAccessModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/index.ts new file mode 100644 index 0000000000..c628694818 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/index.ts @@ -0,0 +1 @@ +export { default } from "./EntraConditionalAccessModal"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx new file mode 100644 index 0000000000..703b2b5965 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx @@ -0,0 +1,392 @@ +import React, { useCallback, useContext, useState } from "react"; +import { size } from "lodash"; +import { useQuery } from "react-query"; + +import { NotificationContext } from "context/notification"; +import { AppContext } from "context/app"; +import configAPI from "services/entities/config"; +import conditionalAccessAPI from "services/entities/conditional_access"; +import { IConfig } from "interfaces/config"; +import endpoints from "utilities/endpoints"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import CustomLink from "components/CustomLink"; +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; +import TooltipWrapper from "components/TooltipWrapper"; +import { IInputFieldParseTarget } from "interfaces/form_field"; +import { getErrorReason } from "interfaces/errors"; +import { + DEFAULT_USE_QUERY_OPTIONS, + LEARN_MORE_ABOUT_BASE_LINK, +} from "utilities/constants"; +import FileUploader from "components/FileUploader"; +import valid_url from "components/forms/validators/valid_url"; + +const baseClass = "okta-conditional-access-modal"; + +const OKTA_IDP_ID = "okta_idp_id"; +const OKTA_ACS_URL = "okta_acs_url"; +const OKTA_AUDIENCE_URI = "okta_audience_uri"; +const OKTA_CERTIFICATE = "okta_certificate"; + +interface IFormData { + [OKTA_IDP_ID]: string; + [OKTA_ACS_URL]: string; + [OKTA_AUDIENCE_URI]: string; + [OKTA_CERTIFICATE]: string; +} + +interface IFormErrors { + [OKTA_IDP_ID]?: string | null; + [OKTA_ACS_URL]?: string | null; + [OKTA_AUDIENCE_URI]?: string | null; + [OKTA_CERTIFICATE]?: string | null; +} + +const validate = (formData: IFormData) => { + const errs: IFormErrors = {}; + + // Max lengths from backend validation + const maxURLLength = 2048; + const maxCertLength = 8192; + + // IdP ID validation - must be non-empty and not just whitespace + if (!formData[OKTA_IDP_ID] || !formData[OKTA_IDP_ID].trim()) { + errs[OKTA_IDP_ID] = "IdP ID must be present"; + } else if (formData[OKTA_IDP_ID].length > maxURLLength) { + errs[OKTA_IDP_ID] = `IdP ID must be ${maxURLLength} characters or less`; + } + + // Assertion Consumer Service URL validation + if (!formData[OKTA_ACS_URL] || !formData[OKTA_ACS_URL].trim()) { + errs[OKTA_ACS_URL] = "Assertion Consumer Service URL must be present"; + } else if (formData[OKTA_ACS_URL].length > maxURLLength) { + errs[ + OKTA_ACS_URL + ] = `Assertion Consumer Service URL must be ${maxURLLength} characters or less`; + } else if ( + !valid_url({ url: formData[OKTA_ACS_URL], protocols: ["http", "https"] }) + ) { + errs[OKTA_ACS_URL] = + "Assertion Consumer Service URL must be a valid URL with http or https scheme and a host"; + } + + // Audience URI validation + if (!formData[OKTA_AUDIENCE_URI] || !formData[OKTA_AUDIENCE_URI].trim()) { + errs[OKTA_AUDIENCE_URI] = "Audience URI must be present"; + } else if (formData[OKTA_AUDIENCE_URI].length > maxURLLength) { + errs[ + OKTA_AUDIENCE_URI + ] = `Audience URI must be ${maxURLLength} characters or less`; + } + + // Certificate validation + if (!formData[OKTA_CERTIFICATE] || !formData[OKTA_CERTIFICATE].trim()) { + errs[OKTA_CERTIFICATE] = "Certificate must be present"; + } else if (formData[OKTA_CERTIFICATE].length > maxCertLength) { + errs[ + OKTA_CERTIFICATE + ] = `Certificate must be ${maxCertLength} characters or less`; + } + + return errs; +}; + +export interface IOktaConditionalAccessModalProps { + onCancel: () => void; + onSuccess: (updatedConfig: IConfig) => void; +} + +const OktaConditionalAccessModal = ({ + onCancel, + onSuccess, +}: IOktaConditionalAccessModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const { config } = useContext(AppContext); + + const [isUpdating, setIsUpdating] = useState(false); + const [formData, setFormData] = useState({ + [OKTA_IDP_ID]: "", + [OKTA_ACS_URL]: "", + [OKTA_AUDIENCE_URI]: "", + [OKTA_CERTIFICATE]: "", + }); + const [formErrors, setFormErrors] = useState({}); + const [certFile, setCertFile] = useState(null); + + // Fetch Apple profile with automatic retries + const { data: appleProfile = "" } = useQuery( + ["appleProfile"], + conditionalAccessAPI.getIdpAppleProfile, + { + ...DEFAULT_USE_QUERY_OPTIONS, + onError: (e: any) => { + // When responseType is "text", error responses come back as JSON strings + // that need to be parsed manually + let errorReason = ""; + try { + if (e.data && typeof e.data === "string") { + const parsedError = JSON.parse(e.data); + errorReason = parsedError.errors?.[0]?.reason || ""; + } else { + errorReason = getErrorReason(e); + } + } catch { + errorReason = getErrorReason(e); + } + + const message = errorReason + ? `Failed to load Apple profile: ${errorReason}` + : "Failed to load Apple profile."; + renderFlash("error", message); + }, + } + ); + + const onSubmit = async (evt: React.FormEvent) => { + evt.preventDefault(); + + const errs = validate(formData); + if (Object.keys(errs).length > 0) { + setFormErrors(errs); + return; + } + if (!config) { + return; + } + setIsUpdating(true); + try { + const updatedConfig = await configAPI.update({ + conditional_access: { + okta_idp_id: formData[OKTA_IDP_ID], + okta_assertion_consumer_service_url: formData[OKTA_ACS_URL], + okta_audience_uri: formData[OKTA_AUDIENCE_URI], + okta_certificate: formData[OKTA_CERTIFICATE], + // Preserve existing Microsoft Entra settings + microsoft_entra_tenant_id: + config.conditional_access?.microsoft_entra_tenant_id || "", + }, + }); + renderFlash("success", "Successfully configured Okta conditional access"); + setIsUpdating(false); + onSuccess(updatedConfig); + } catch (e) { + renderFlash( + "error", + "Could not update conditional access integration settings." + ); + setIsUpdating(false); + } + }; + + const onInputChange = ({ name, value }: IInputFieldParseTarget) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + const newErrs = validate(newFormData); + // only set errors that are updates of existing errors + // new errors are only set onBlur or submit + const errsToSet: Record = {}; + Object.keys(formErrors).forEach((k) => { + // @ts-ignore + if (newErrs[k]) { + // @ts-ignore + errsToSet[k] = newErrs[k]; + } + }); + setFormErrors(errsToSet); + }; + + const onInputBlur = () => { + setFormErrors(validate(formData)); + }; + + const onDeleteFile = () => { + setCertFile(null); + setFormData({ ...formData, [OKTA_CERTIFICATE]: "" }); + setFormErrors({ + ...formErrors, + [OKTA_CERTIFICATE]: "Certificate must be present", + }); + }; + + const onSelectFile = useCallback( + (files: FileList | null) => { + const file = files?.[0]; + if (!file) return; + + // Validate file extension + if (!file.name.match(/\.(pem|crt|cer|cert)$/i)) { + renderFlash( + "error", + "Invalid file type. Please upload a .pem, .crt, .cer, or .cert file." + ); + return; + } + + const reader = new FileReader(); + reader.readAsText(file); + + reader.addEventListener("load", () => { + const content = reader.result as string; + + // Validate PEM format + if ( + !content.includes("-----BEGIN CERTIFICATE-----") || + !content.includes("-----END CERTIFICATE-----") + ) { + renderFlash( + "error", + "Invalid certificate format. The file must be a valid PEM-encoded certificate." + ); + return; + } + + // Create new form data with the certificate + const newFormData = { ...formData, [OKTA_CERTIFICATE]: content }; + + // Store the certificate content and file details + setCertFile(file); + setFormData(newFormData); + // Re-validate the entire form to clear errors if all fields are now complete + setFormErrors(validate(newFormData)); + }); + + reader.addEventListener("error", () => { + renderFlash("error", "Failed to read the certificate file."); + }); + }, + [formData, renderFlash] + ); + + return ( + + <> +
+

+ To configure Okta conditional access, follow the instructions in the{" "} + +

+ + {/* IdP Signature Certificate Section */} +
+ + Identity provider (IdP) signature certificate + +
+ +
+ Download certificate +
+
+
+ + {/* User Scope Profile */} + + + {/* Help text */} +

+ You can find the following fields in Okta after creating an IdP in{" "} + Security > Identity Providers{" "} + > SAML 2.0 IdP. +

+ + + + + + {/* Certificate file uploader with inline validation error display. + Note: This is a custom pattern - FileUploader doesn't have built-in error prop like InputField. + Other FileUploader usages in the codebase use flash notifications instead of inline errors, + but this form requires field-level validation consistency with the InputFields above. */} +
+ {formErrors[OKTA_CERTIFICATE] && ( + + {formErrors[OKTA_CERTIFICATE]} + + )} + + Upload the certificate provided by Okta during the{" "} + Set Up Authenticator workflow + + } + onFileUpload={onSelectFile} + buttonType="brand-inverse-icon" + buttonMessage="Upload" + accept=".pem,.crt,.cer,.cert" + fileDetails={certFile ? { name: certFile.name } : undefined} + onDeleteFile={onDeleteFile} + /> +
+ +
+ + +
+ + +
+ ); +}; + +export default OktaConditionalAccessModal; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/_styles.scss new file mode 100644 index 0000000000..64176b8c24 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/_styles.scss @@ -0,0 +1,28 @@ +.okta-conditional-access-modal { + .file-uploader__title { + color: $ui-fleet-black-75; + font-size: $small; + font-weight: $bold; + } + + // User scope profile read-only textarea + .input-field--read-only { + .input-field__textarea { + background-color: $ui-off-white; + } + } + + // Error message above file uploader + &__file-uploader-error { + color: $core-vibrant-red; + font-size: $x-small; + font-weight: $bold; + } + + // Certificate file uploader wrapper + &__file-uploader-wrapper { + display: flex; + flex-direction: column; + gap: $pad-small; // 0.5rem gap between error and FileUploader + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/index.ts b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/index.ts new file mode 100644 index 0000000000..33b7a15c95 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/index.ts @@ -0,0 +1 @@ +export { default } from "./OktaConditionalAccessModal"; diff --git a/frontend/services/entities/conditional_access.ts b/frontend/services/entities/conditional_access.ts index 124380f2a4..cc0674ac96 100644 --- a/frontend/services/entities/conditional_access.ts +++ b/frontend/services/entities/conditional_access.ts @@ -24,6 +24,14 @@ const conditionalAccessService = { deleteMicrosoftConditionalAccess: () => { return sendRequest("DELETE", endpoints.CONDITIONAL_ACCESS_MICROSOFT); }, + getIdpAppleProfile: (): Promise => { + return sendRequest( + "GET", + endpoints.CONDITIONAL_ACCESS_IDP_APPLE_PROFILE, + undefined, + "text" + ); + }, }; export default conditionalAccessService; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 489d32b75c..8646b04225 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -18,6 +18,8 @@ export default { // Conditional access CONDITIONAL_ACCESS_MICROSOFT: `/${API_VERSION}/fleet/conditional-access/microsoft`, CONDITIONAL_ACCESS_MICROSOFT_CONFIRM: `/${API_VERSION}/fleet/conditional-access/microsoft/confirm`, + CONDITIONAL_ACCESS_IDP_SIGNING_CERT: `/${API_VERSION}/fleet/conditional_access/idp/signing_cert`, + CONDITIONAL_ACCESS_IDP_APPLE_PROFILE: `/${API_VERSION}/fleet/conditional_access/idp/apple/profile`, CONFIG: `/${API_VERSION}/fleet/config`, CONFIRM_EMAIL_CHANGE: (token: string): string => {