diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml
index 347c44aedd9..a498fa80035 100644
--- a/.fides/db_dataset.yml
+++ b/.fides/db_dataset.yml
@@ -2230,4 +2230,43 @@ dataset:
- name: type
data_categories: [system.operations]
- name: updated_at
- data_categories: [system.operations]
\ No newline at end of file
+ data_categories: [system.operations]
+ - name: openid_provider
+ description: 'Fides Generated Description for Table: openid_provider'
+ fields:
+ - name: authorization_url
+ description: 'Fides Generated Description for Column: authorization_url'
+ data_categories: [system.operations]
+ - name: client_id
+ description: 'Fides Generated Description for Column: client_id'
+ data_categories: [system.operations]
+ - name: client_secret
+ description: 'Fides Generated Description for Column: client_secret'
+ data_categories: [system.operations]
+ - name: created_at
+ description: 'Fides Generated Description for Column: created_at'
+ data_categories: [system.operations]
+ - name: domain
+ description: 'Fides Generated Description for Column: domain'
+ data_categories: [system.operations]
+ - name: id
+ description: 'Fides Generated Description for Column: id'
+ data_categories: [system.operations]
+ - name: identifier
+ description: 'Fides Generated Description for Column: identifier'
+ data_categories: [system.operations]
+ - name: name
+ description: 'Fides Generated Description for Column: name'
+ data_categories: [system.operations]
+ - name: provider
+ description: 'Fides Generated Description for Column: provider'
+ data_categories: [system.operations]
+ - name: token_url
+ description: 'Fides Generated Description for Column: token_url'
+ data_categories: [system.operations]
+ - name: updated_at
+ description: 'Fides Generated Description for Column: updated_at'
+ data_categories: [system.operations]
+ - name: user_info_url
+ description: 'Fides Generated Description for Column: user_info_url'
+ data_categories: [system.operations]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae49f413219..371ec789e8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ The types of changes are:
### Added
- Added support for mapping a system's inegration's consentable items to privacy notices [#5156](https://github.com/ethyca/fides/pull/5156)
+- Added support for SSO Login with multiple providers (Fides Plus feature) [#5134](https://github.com/ethyca/fides/pull/5134)
### Fixed
- Fixed the OAuth2 configuration for the Snap integration [#5158](https://github.com/ethyca/fides/pull/5158)
diff --git a/clients/admin-ui/public/images/oauth-login/custom.svg b/clients/admin-ui/public/images/oauth-login/custom.svg
new file mode 100644
index 00000000000..a3b4d23e17a
--- /dev/null
+++ b/clients/admin-ui/public/images/oauth-login/custom.svg
@@ -0,0 +1,3 @@
+
diff --git a/clients/admin-ui/public/images/oauth-login/github.svg b/clients/admin-ui/public/images/oauth-login/github.svg
new file mode 100644
index 00000000000..37fa923df33
--- /dev/null
+++ b/clients/admin-ui/public/images/oauth-login/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/clients/admin-ui/public/images/oauth-login/google.svg b/clients/admin-ui/public/images/oauth-login/google.svg
new file mode 100644
index 00000000000..0b0dfe3a45f
--- /dev/null
+++ b/clients/admin-ui/public/images/oauth-login/google.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/clients/admin-ui/public/images/oauth-login/okta.svg b/clients/admin-ui/public/images/oauth-login/okta.svg
new file mode 100644
index 00000000000..0653992a716
--- /dev/null
+++ b/clients/admin-ui/public/images/oauth-login/okta.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/clients/admin-ui/src/features/auth/auth.slice.ts b/clients/admin-ui/src/features/auth/auth.slice.ts
index 74a2df834c4..92378f720fd 100644
--- a/clients/admin-ui/src/features/auth/auth.slice.ts
+++ b/clients/admin-ui/src/features/auth/auth.slice.ts
@@ -9,6 +9,7 @@ import { RoleRegistryEnum, ScopeRegistryEnum } from "~/types/api";
import {
LoginRequest,
LoginResponse,
+ LoginWithOIDCRequest,
LogoutRequest,
LogoutResponse,
} from "./types";
@@ -58,6 +59,13 @@ const authApi = baseApi.injectEndpoints({
}),
invalidatesTags: () => ["Auth"],
}),
+ loginWithOIDC: build.mutation({
+ query: (data) => ({
+ url: `plus/openid-provider/${data.provider}/callback?code=${data.code}`,
+ method: "GET",
+ }),
+ invalidatesTags: () => ["Auth"],
+ }),
logout: build.mutation({
query: () => ({
url: "logout",
@@ -85,6 +93,7 @@ const authApi = baseApi.injectEndpoints({
export const {
useLoginMutation,
+ useLoginWithOIDCMutation,
useLogoutMutation,
useAcceptInviteMutation,
useGetRolesToScopesMappingQuery,
diff --git a/clients/admin-ui/src/features/auth/types.ts b/clients/admin-ui/src/features/auth/types.ts
index 4490b63ce91..bff00b97fdc 100644
--- a/clients/admin-ui/src/features/auth/types.ts
+++ b/clients/admin-ui/src/features/auth/types.ts
@@ -5,6 +5,11 @@ export interface LoginRequest {
password: string;
}
+export interface LoginWithOIDCRequest {
+ provider: string;
+ code: string;
+}
+
export interface LoginResponse {
user_data: User;
token_data: {
diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts
index 0e87da306bf..e3ec934b0a8 100644
--- a/clients/admin-ui/src/features/common/api.slice.ts
+++ b/clients/admin-ui/src/features/common/api.slice.ts
@@ -62,6 +62,7 @@ export const baseApi = createApi({
"Configuration Settings",
"TCF Purpose Override",
"Consent Reporting",
+ "OpenID Provider",
],
endpoints: () => ({}),
});
diff --git a/clients/admin-ui/src/features/common/nav/v2/routes.ts b/clients/admin-ui/src/features/common/nav/v2/routes.ts
index 5a49edc22fa..9f97048cd8c 100644
--- a/clients/admin-ui/src/features/common/nav/v2/routes.ts
+++ b/clients/admin-ui/src/features/common/nav/v2/routes.ts
@@ -55,3 +55,6 @@ export const GLOBAL_CONSENT_CONFIG_ROUTE = "/settings/consent";
export const MESSAGING_ROUTE = "/messaging";
export const MESSAGING_ADD_TEMPLATE_ROUTE = "/messaging/add-template";
export const MESSAGING_EDIT_ROUTE = "/messaging/[id]";
+
+// OpenID Authentication group
+export const OPENID_AUTHENTICATION_ROUTE = "/settings/openid-authentication";
diff --git a/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx b/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx
new file mode 100644
index 00000000000..25a9e0d7b7e
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx
@@ -0,0 +1,15 @@
+import { UseDisclosureReturn } from "fidesui";
+
+import AddModal from "~/features/configure-consent/AddModal";
+import SSOProviderForm from "~/features/openid-authentication/SSOProviderForm";
+
+const AddSSOProviderModal = ({
+ isOpen,
+ onClose,
+}: Pick) => (
+
+
+
+);
+
+export default AddSSOProviderModal;
diff --git a/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx b/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx
new file mode 100644
index 00000000000..2bfc3d6675a
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx
@@ -0,0 +1,19 @@
+import AddModal from "~/features/configure-consent/AddModal";
+import SSOProviderForm from "~/features/openid-authentication/SSOProviderForm";
+import { OpenIDProvider } from "~/types/api/models/OpenIDProvider";
+
+const EditSSOProviderModal = ({
+ isOpen,
+ onClose,
+ openIDProvider,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+ openIDProvider: OpenIDProvider;
+}) => (
+
+
+
+);
+
+export default EditSSOProviderModal;
diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx
new file mode 100644
index 00000000000..e16adc76738
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx
@@ -0,0 +1,108 @@
+import {
+ Box,
+ Button,
+ ConfirmationModal,
+ Image,
+ Text,
+ useDisclosure,
+ useToast,
+} from "fidesui";
+
+import { getErrorMessage, isErrorResult } from "~/features/common/helpers";
+import { errorToastParams, successToastParams } from "~/features/common/toast";
+import EditSSOProviderModal from "~/features/openid-authentication/EditSSOProviderModal";
+import { useDeleteOpenIDProviderMutation } from "~/features/openid-authentication/openprovider.slice";
+import { OpenIDProvider } from "~/types/api/models/OpenIDProvider";
+
+const SSOProvider = ({
+ openIDProvider,
+}: {
+ openIDProvider: OpenIDProvider;
+}) => {
+ const { onOpen, isOpen, onClose } = useDisclosure();
+ const { isOpen: deleteIsOpen, onClose: onDeleteClose } = useDisclosure();
+ const toast = useToast();
+
+ const [deleteOpenIDProviderMutation] = useDeleteOpenIDProviderMutation();
+
+ const handleDelete = async () => {
+ const result = await deleteOpenIDProviderMutation(openIDProvider.id);
+ if (isErrorResult(result)) {
+ toast(errorToastParams(getErrorMessage(result.error)));
+ onDeleteClose();
+ return;
+ }
+
+ toast(successToastParams(`OpenID Provider deleted successfully`));
+
+ onDeleteClose();
+ };
+
+ return (
+
+
+
+
+
+ {openIDProvider.name}
+
+
+ {openIDProvider.identifier}
+
+
+
+
+
+
+
+
+
+ You are about to permanently remove this SSO provider. Are you sure
+ you would like to continue?
+
+ }
+ />
+
+ );
+};
+
+export default SSOProvider;
diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx
new file mode 100644
index 00000000000..ae83e655cf5
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx
@@ -0,0 +1,224 @@
+import { SerializedError } from "@reduxjs/toolkit";
+import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
+import { Box, Button, Stack, useToast } from "fidesui";
+import { Form, Formik, FormikHelpers } from "formik";
+import { useMemo } from "react";
+import * as Yup from "yup";
+
+import { CustomSelect, CustomTextInput } from "~/features/common/form/inputs";
+import { getErrorMessage, isErrorResult } from "~/features/common/helpers";
+import { errorToastParams, successToastParams } from "~/features/common/toast";
+import {
+ useCreateOpenIDProviderMutation,
+ useUpdateOpenIDProviderMutation,
+} from "~/features/openid-authentication/openprovider.slice";
+import { OpenIDProvider } from "~/types/api";
+
+interface SSOProviderFormProps {
+ openIDProvider?: OpenIDProvider;
+ onSuccess?: (openIDProvider: OpenIDProvider) => void;
+ onClose: () => void;
+}
+
+export interface SSOProviderFormValues extends OpenIDProvider {}
+
+export const defaultInitialValues: SSOProviderFormValues = {
+ id: "",
+ identifier: "",
+ name: "",
+ provider: "",
+ client_id: "",
+ client_secret: "",
+};
+
+export const transformOrganizationToFormValues = (
+ openIDProvider: OpenIDProvider,
+): SSOProviderFormValues => ({ ...openIDProvider });
+
+const SSOProviderFormValidationSchema = Yup.object().shape({
+ provider: Yup.string().required().label("Provider"),
+ name: Yup.string().required().label("Name"),
+ client_id: Yup.string().required().label("Client ID"),
+ client_secret: Yup.string().required().label("Client Secret"),
+});
+
+const SSOProviderForm = ({
+ openIDProvider,
+ onSuccess,
+ onClose,
+}: SSOProviderFormProps) => {
+ const [createOpenIDProviderMutationTrigger] =
+ useCreateOpenIDProviderMutation();
+ const [updateOpenIDProviderMutation] = useUpdateOpenIDProviderMutation();
+
+ const initialValues = useMemo(
+ () =>
+ openIDProvider
+ ? transformOrganizationToFormValues(openIDProvider)
+ : defaultInitialValues,
+ [openIDProvider],
+ );
+
+ const toast = useToast();
+
+ const handleSubmit = async (
+ values: SSOProviderFormValues,
+ formikHelpers: FormikHelpers,
+ ) => {
+ const handleResult = (
+ result:
+ | { data: object }
+ | { error: FetchBaseQueryError | SerializedError },
+ ) => {
+ if (isErrorResult(result)) {
+ const errorMsg = getErrorMessage(
+ result.error,
+ "An unexpected error occurred while editing the OpenID Provider. Please try again.",
+ );
+ toast(errorToastParams(errorMsg));
+ } else {
+ toast(successToastParams("OpenID Provider configuration saved."));
+ onClose();
+ formikHelpers.resetForm({});
+ if (onSuccess) {
+ onSuccess(values);
+ }
+ }
+ };
+ if (initialValues.id) {
+ const result = await updateOpenIDProviderMutation(values);
+ handleResult(result);
+ } else {
+ const result = await createOpenIDProviderMutationTrigger(values);
+ handleResult(result);
+ }
+ };
+
+ const PROVIDER_OPTIONS = [
+ { label: "Google", value: "google" },
+ { label: "Okta", value: "okta" },
+ { label: "Custom", value: "custom" },
+ ];
+
+ const renderOktaProviderExtraFields = () => (
+
+ );
+
+ const renderCustomProviderExtraFields = () => (
+ <>
+
+
+
+ >
+ );
+
+ return (
+
+ {({ dirty, isValid, values }) => (
+
+ )}
+
+ );
+};
+
+export default SSOProviderForm;
diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx
new file mode 100644
index 00000000000..8227a04c9b0
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx
@@ -0,0 +1,47 @@
+import { Box, Button, Heading, Text, useDisclosure } from "fidesui";
+
+import AddSSOProviderModal from "~/features/openid-authentication/AddSSOProviderModal";
+import { useGetAllOpenIDProvidersQuery } from "~/features/openid-authentication/openprovider.slice";
+import SSOProvider from "~/features/openid-authentication/SSOProvider";
+import { OpenIDProvider } from "~/types/api/models/OpenIDProvider";
+
+const SSOProvidersSection = () => {
+ const { onOpen, isOpen, onClose } = useDisclosure();
+ const { data: openidProviders } = useGetAllOpenIDProvidersQuery();
+
+ const renderItems: () => JSX.Element[] | undefined = () =>
+ openidProviders?.map((item: OpenIDProvider) => (
+
+ ));
+
+ return (
+
+
+ SSO Providers
+ {openidProviders && openidProviders.length < 5 && (
+
+ )}
+
+
+ Use this area to add and manage SSO providers for you organization.
+ Select “Add SSO provider” to add a new provider.
+
+ {renderItems()}
+
+
+ );
+};
+
+export default SSOProvidersSection;
diff --git a/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts b/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts
new file mode 100644
index 00000000000..e26f94edf4c
--- /dev/null
+++ b/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts
@@ -0,0 +1,57 @@
+import { baseApi } from "~/features/common/api.slice";
+import { OpenIDProvider } from "~/types/api";
+
+interface OpenIDProviderDeleteResponse {
+ message: string;
+ resource: OpenIDProvider;
+}
+
+const openIDProviderApi = baseApi.injectEndpoints({
+ endpoints: (build) => ({
+ getAllOpenIDProvidersSimple: build.query({
+ query: () => ({ url: `plus/openid-provider/simple` }),
+ providesTags: ["OpenID Provider"],
+ }),
+ getAllOpenIDProviders: build.query({
+ query: () => ({ url: `plus/openid-provider` }),
+ providesTags: ["OpenID Provider"],
+ }),
+ createOpenIDProvider: build.mutation<
+ OpenIDProvider,
+ OpenIDProvider | unknown
+ >({
+ query: (body) => ({
+ url: `plus/openid-provider`,
+ method: "POST",
+ body,
+ }),
+ invalidatesTags: ["OpenID Provider"],
+ }),
+ deleteOpenIDProvider: build.mutation({
+ query: (key) => ({
+ url: `plus/openid-provider/${key}`,
+ method: "DELETE",
+ }),
+ invalidatesTags: ["OpenID Provider"],
+ }),
+ updateOpenIDProvider: build.mutation<
+ OpenIDProvider,
+ Partial & Pick
+ >({
+ query: (params) => ({
+ url: `plus/openid-provider/${params.id}`,
+ method: "PATCH",
+ body: params,
+ }),
+ invalidatesTags: ["OpenID Provider"],
+ }),
+ }),
+});
+
+export const {
+ useGetAllOpenIDProvidersSimpleQuery,
+ useGetAllOpenIDProvidersQuery,
+ useCreateOpenIDProviderMutation,
+ useUpdateOpenIDProviderMutation,
+ useDeleteOpenIDProviderMutation,
+} = openIDProviderApi;
diff --git a/clients/admin-ui/src/features/user-management/UserForm.tsx b/clients/admin-ui/src/features/user-management/UserForm.tsx
index efa3769ca0e..0fa92907c57 100644
--- a/clients/admin-ui/src/features/user-management/UserForm.tsx
+++ b/clients/admin-ui/src/features/user-management/UserForm.tsx
@@ -20,6 +20,7 @@ import DeleteUserModal from "user-management/DeleteUserModal";
import * as Yup from "yup";
import { useAppDispatch, useAppSelector } from "~/app/hooks";
+import { useFlags } from "~/features/common/features";
import { CustomTextInput } from "~/features/common/form/inputs";
import { passwordValidation } from "~/features/common/form/validation";
import { getErrorMessage, isErrorResult } from "~/features/common/helpers";
@@ -27,6 +28,7 @@ import { TrashCanSolidIcon } from "~/features/common/Icon/TrashCanSolidIcon";
import { USER_MANAGEMENT_ROUTE } from "~/features/common/nav/v2/routes";
import { errorToastParams, successToastParams } from "~/features/common/toast";
import { useGetEmailInviteStatusQuery } from "~/features/messaging/messaging.slice";
+import { useGetAllOpenIDProvidersQuery } from "~/features/openid-authentication/openprovider.slice";
import PasswordManagement from "./PasswordManagement";
import { User, UserCreate, UserCreateResponse } from "./types";
@@ -71,6 +73,7 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: Props) => {
const activeUser = useAppSelector(selectActiveUser);
const { data: emailInviteStatus } = useGetEmailInviteStatusQuery();
const inviteUsersViaEmail = emailInviteStatus?.enabled;
+ const { flags } = useFlags();
const isNewUser = !activeUser;
const nameDisabled = isNewUser ? false : !canEditNames;
@@ -94,16 +97,29 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: Props) => {
}`,
),
);
- if (result && result.data) {
+ if (result?.data) {
dispatch(setActiveUserId(result.data.id));
}
};
// The password field is only available when creating a new user.
// Otherwise, it is within the UpdatePasswordModal
- const validationSchema = showPasswordField
- ? ValidationSchema
- : ValidationSchema.omit(["password"]);
+ let validationSchema: Yup.ObjectSchema = ValidationSchema;
+
+ const { data: openidProviders } = useGetAllOpenIDProvidersQuery();
+
+ const passwordFieldIsRequired =
+ !flags.openIDAuthentication || !openidProviders?.length;
+
+ if (!passwordFieldIsRequired) {
+ validationSchema = ValidationSchema.shape({
+ password: passwordValidation.optional().label("Password"),
+ });
+ }
+
+ validationSchema = showPasswordField
+ ? validationSchema
+ : validationSchema.omit(["password"]);
return (
{
placeholder="********"
type="password"
tooltip="Password must contain at least 8 characters, 1 number, 1 capital letter, 1 lowercase letter, and at least 1 symbol."
- isRequired
+ isRequired={passwordFieldIsRequired}
/>
) : null}
diff --git a/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx b/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx
index 56d1fb7124a..19f527f5a5b 100644
--- a/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx
+++ b/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx
@@ -8,6 +8,7 @@ import { USER_MANAGEMENT_ROUTE } from "~/features/common/nav/v2/routes";
interface Props {
children: React.ReactNode;
}
+
const Profile = ({ children }: Props) => (
diff --git a/clients/admin-ui/src/flags.json b/clients/admin-ui/src/flags.json
index dbba97dde72..973b5aae1ba 100644
--- a/clients/admin-ui/src/flags.json
+++ b/clients/admin-ui/src/flags.json
@@ -35,5 +35,11 @@
"development": true,
"test": true,
"production": false
+ },
+ "openIDAuthentication": {
+ "description": "OpenID authentication",
+ "development": true,
+ "test": true,
+ "production": false
}
}
diff --git a/clients/admin-ui/src/pages/_app.tsx b/clients/admin-ui/src/pages/_app.tsx
index a0b2fa4d474..b02a0a337b2 100644
--- a/clients/admin-ui/src/pages/_app.tsx
+++ b/clients/admin-ui/src/pages/_app.tsx
@@ -18,6 +18,7 @@ import MainSideNav from "~/features/common/nav/v2/MainSideNav";
import store, { persistor } from "../app/store";
import theme from "../theme";
import Login from "./login";
+import LoginWithOIDC from "./login/[provider]";
if (process.env.NEXT_PUBLIC_MOCK_API) {
// eslint-disable-next-line global-require
@@ -36,7 +37,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => (
- {Component === Login ? (
+ {Component === Login || Component === LoginWithOIDC ? (
// Only the login page is accessible while logged out. If there is
// a use case for more unprotected routes, Next has a guide for
// per-page layouts:
diff --git a/clients/admin-ui/src/pages/login.tsx b/clients/admin-ui/src/pages/login.tsx
index 0058e8e87e3..d7bfa2ad750 100644
--- a/clients/admin-ui/src/pages/login.tsx
+++ b/clients/admin-ui/src/pages/login.tsx
@@ -31,6 +31,7 @@ import {
} from "~/features/auth";
import { CustomTextInput } from "~/features/common/form/inputs";
import { passwordValidation } from "~/features/common/form/validation";
+import { useGetAllOpenIDProvidersSimpleQuery } from "~/features/openid-authentication/openprovider.slice";
const parseQueryParam = (query: ParsedUrlQuery) => {
const validPathRegex = /^\/[\w/-]*$/;
@@ -178,6 +179,37 @@ const useLogin = () => {
};
};
+const OAuthLoginButtons = () => {
+ const { data: openidProviders } = useGetAllOpenIDProvidersSimpleQuery();
+
+ return (
+
+
+ {openidProviders?.map((provider) => (
+
+ }
+ width="100%"
+ colorScheme="gray"
+ variant="outline"
+ >
+ Sign in with {provider.name}
+
+ ))}
+
+
+ );
+};
+
const Login: NextPage = () => {
const { isFromInvite, showAnimation, inviteCode, ...formikProps } =
useLogin();
@@ -292,6 +324,7 @@ const Login: NextPage = () => {
{showAnimation ? : null}
+
diff --git a/clients/admin-ui/src/pages/login/[provider].tsx b/clients/admin-ui/src/pages/login/[provider].tsx
new file mode 100644
index 00000000000..0cf632ec1cd
--- /dev/null
+++ b/clients/admin-ui/src/pages/login/[provider].tsx
@@ -0,0 +1,46 @@
+import { Center, Spinner, useToast } from "fidesui";
+import type { NextPage } from "next";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+import { useDispatch } from "react-redux";
+
+import { login, useLoginWithOIDCMutation } from "~/features/auth";
+import { LoginWithOIDCRequest } from "~/features/auth/types";
+
+const LoginWithOIDC: NextPage = () => {
+ const router = useRouter();
+ const dispatch = useDispatch();
+ const [loginRequest] = useLoginWithOIDCMutation();
+ const toast = useToast();
+
+ useEffect(() => {
+ if (!router.query || !router.query.provider || !router.query.code) {
+ return;
+ }
+ const data: LoginWithOIDCRequest = {
+ provider: router.query.provider as string,
+ code: router.query.code as string,
+ };
+ loginRequest(data)
+ .unwrap()
+ .then((response) => {
+ dispatch(login(response));
+ router.push("/");
+ })
+ .catch((error) => {
+ toast({
+ status: "error",
+ description: error?.data?.detail,
+ });
+ router.push("/login");
+ });
+ }, [router, toast, dispatch, router.query, loginRequest]);
+
+ return (
+
+
+
+ );
+};
+
+export default LoginWithOIDC;
diff --git a/clients/admin-ui/src/pages/settings/organization.tsx b/clients/admin-ui/src/pages/settings/organization.tsx
index f77c931209d..5785f715313 100644
--- a/clients/admin-ui/src/pages/settings/organization.tsx
+++ b/clients/admin-ui/src/pages/settings/organization.tsx
@@ -1,17 +1,25 @@
+import Restrict from "common/Restrict";
import { Box, Heading, Text } from "fidesui";
import type { NextPage } from "next";
+import { useFeatures } from "~/features/common/features";
import Layout from "~/features/common/Layout";
+import OpenIDAuthenticationSection from "~/features/openid-authentication/SSOProvidersSection";
import {
DEFAULT_ORGANIZATION_FIDES_KEY,
useGetOrganizationByFidesKeyQuery,
} from "~/features/organization";
import { OrganizationForm } from "~/features/organization/OrganizationForm";
+import { ScopeRegistryEnum } from "~/types/api";
const OrganizationPage: NextPage = () => {
const { data: organization } = useGetOrganizationByFidesKeyQuery(
DEFAULT_ORGANIZATION_FIDES_KEY,
);
+ const {
+ plus: hasPlus,
+ flags: { openIDAuthentication },
+ } = useFeatures();
return (
@@ -28,6 +36,11 @@ const OrganizationPage: NextPage = () => {
+ {openIDAuthentication && hasPlus && (
+
+
+
+ )}
diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts
index ca4e469f5cd..c62ab73ef0f 100644
--- a/clients/admin-ui/src/types/api/index.ts
+++ b/clients/admin-ui/src/types/api/index.ts
@@ -306,6 +306,7 @@ export type { PreApprovalWebhookResponse } from "./models/PreApprovalWebhookResp
export type { PreApprovalWebhookUpdate } from "./models/PreApprovalWebhookUpdate";
export type { PreferencesSaved } from "./models/PreferencesSaved";
export type { PreferencesSavedExtended } from "./models/PreferencesSavedExtended";
+export type { OpenIDProvider } from "./models/OpenIDProvider";
export type { PreferenceWithNoticeInformation } from "./models/PreferenceWithNoticeInformation";
export type { PrivacyCenterConfig } from "./models/PrivacyCenterConfig";
export type { PrivacyDeclaration } from "./models/PrivacyDeclaration";
diff --git a/clients/admin-ui/src/types/api/models/OpenIDProvider.ts b/clients/admin-ui/src/types/api/models/OpenIDProvider.ts
new file mode 100644
index 00000000000..b3ecd2307d6
--- /dev/null
+++ b/clients/admin-ui/src/types/api/models/OpenIDProvider.ts
@@ -0,0 +1,12 @@
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+
+export type OpenIDProvider = {
+ id: string;
+ identifier: string;
+ name: string;
+ provider: string;
+ client_id: string;
+ client_secret: string;
+};
diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
index 365959b9cb0..9905b9aea26 100644
--- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
+++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
@@ -90,6 +90,10 @@ export enum ScopeRegistryEnum {
MESSAGING_CREATE_OR_UPDATE = "messaging:create_or_update",
MESSAGING_DELETE = "messaging:delete",
MESSAGING_READ = "messaging:read",
+ OPENID_PROVIDER_CREATE = "openid_provider:create",
+ OPENID_PROVIDER_READ = "openid_provider:read",
+ OPENID_PROVIDER_UPDATE = "openid_provider:update",
+ OPENID_PROVIDER_DELETE = "openid_provider:delete",
ORGANIZATION_CREATE = "organization:create",
ORGANIZATION_DELETE = "organization:delete",
ORGANIZATION_READ = "organization:read",
diff --git a/noxfiles/ci_nox.py b/noxfiles/ci_nox.py
index f09b6f16d9d..d18428ee1d3 100644
--- a/noxfiles/ci_nox.py
+++ b/noxfiles/ci_nox.py
@@ -82,8 +82,6 @@ def pylint(session: nox.Session) -> None:
"""Run the 'pylint' code linter."""
install_requirements(session)
command = ("pylint", "src", "noxfiles", "noxfile.py", "--jobs", "0")
- if session.posargs:
- command = ("pylint", *session.posargs)
session.run(*command)
diff --git a/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py b/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py
new file mode 100644
index 00000000000..34496288276
--- /dev/null
+++ b/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py
@@ -0,0 +1,77 @@
+"""add openid_provider
+
+Revision ID: ffee79245c9a
+Revises: d69cf8f82a58
+Create Date: 2024-08-09 19:27:07.222226
+
+"""
+
+import sqlalchemy as sa
+import sqlalchemy_utils
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "ffee79245c9a"
+down_revision = "d69cf8f82a58"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "openid_provider",
+ sa.Column("id", sa.String(length=255), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=True,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=True,
+ ),
+ sa.Column("identifier", sa.String(), nullable=True),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column(
+ "provider",
+ sa.Enum("google", "okta", "custom", name="providerenum"),
+ nullable=True,
+ ),
+ sa.Column(
+ "client_id",
+ sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(),
+ nullable=False,
+ ),
+ sa.Column(
+ "client_secret",
+ sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(),
+ nullable=False,
+ ),
+ sa.Column("domain", sa.String(), nullable=True),
+ sa.Column("authorization_url", sa.String(), nullable=True),
+ sa.Column("token_url", sa.String(), nullable=True),
+ sa.Column("user_info_url", sa.String(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ op.f("ix_openid_provider_id"), "openid_provider", ["id"], unique=False
+ )
+ op.create_index(
+ op.f("ix_openid_provider_identifier"),
+ "openid_provider",
+ ["identifier"],
+ unique=True,
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f("ix_openid_provider_identifier"), table_name="openid_provider")
+ op.drop_index(op.f("ix_openid_provider_id"), table_name="openid_provider")
+ op.drop_table("openid_provider")
+ # ### end Alembic commands ###
diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py
index 8a52d33bea3..a9a38d9df8d 100644
--- a/src/fides/api/db/base.py
+++ b/src/fides/api/db/base.py
@@ -21,6 +21,7 @@
from fides.api.models.manual_webhook import AccessManualWebhook
from fides.api.models.messaging import MessagingConfig
from fides.api.models.messaging_template import MessagingTemplate
+from fides.api.models.openid_provider import OpenIDProvider
from fides.api.models.policy import Policy, Rule, RuleTarget
from fides.api.models.privacy_center_config import PrivacyCenterConfig
from fides.api.models.privacy_experience import (
diff --git a/src/fides/api/models/openid_provider.py b/src/fides/api/models/openid_provider.py
new file mode 100644
index 00000000000..c0a5637cdeb
--- /dev/null
+++ b/src/fides/api/models/openid_provider.py
@@ -0,0 +1,53 @@
+import enum
+
+from sqlalchemy import Column
+from sqlalchemy import Enum as EnumColumn
+from sqlalchemy import String
+from sqlalchemy.ext.declarative import declared_attr
+from sqlalchemy_utils.types.encrypted.encrypted_type import (
+ AesGcmEngine,
+ StringEncryptedType,
+)
+
+from fides.api.db.base_class import Base
+from fides.config import CONFIG
+
+
+class ProviderEnum(enum.Enum):
+ google = "google"
+ okta = "okta"
+ custom = "custom"
+
+
+class OpenIDProvider(Base):
+ """The DB ORM model for OpenIDProvider."""
+
+ identifier = Column(String, unique=True, index=True)
+ name = Column(String)
+ provider = Column(EnumColumn(ProviderEnum))
+ client_id = Column(
+ StringEncryptedType(
+ type_in=String(),
+ key=CONFIG.security.app_encryption_key,
+ engine=AesGcmEngine,
+ padding="pkcs5",
+ ),
+ nullable=False,
+ )
+ client_secret = Column(
+ StringEncryptedType(
+ type_in=String(),
+ key=CONFIG.security.app_encryption_key,
+ engine=AesGcmEngine,
+ padding="pkcs5",
+ ),
+ nullable=False,
+ )
+ domain = Column(String, nullable=True) # Used for Okta provider
+ authorization_url = Column(String, nullable=True) # Used for Custom provider
+ token_url = Column(String, nullable=True) # Used for Custom provider
+ user_info_url = Column(String, nullable=True) # Used for Custom provider
+
+ @declared_attr
+ def __tablename__(self) -> str:
+ return "openid_provider"