diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 330b447b5..bdd1de711 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -113,6 +113,9 @@ import { TLdapGroupMaps, TLdapGroupMapsInsert, TLdapGroupMapsUpdate, + TOidcConfigs, + TOidcConfigsInsert, + TOidcConfigsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -255,7 +258,6 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; declare module "knex/types/tables" { interface Tables { diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index 0b54064bc..b11e6bbab 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -6,17 +6,17 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.OidcConfig))) { await knex.schema.createTable(TableName.OidcConfig, (tb) => { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - tb.string("issuer"); - tb.string("authorizationEndpoint"); - tb.string("jwksUri"); - tb.string("tokenEndpoint"); - tb.string("userinfoEndpoint"); - tb.text("encryptedClientId"); - tb.string("clientIdIV"); - tb.string("clientIdTag"); - tb.text("encryptedClientSecret"); - tb.string("clientSecretIV"); - tb.string("clientSecretTag"); + tb.string("issuer").notNullable(); + tb.string("authorizationEndpoint").notNullable(); + tb.string("jwksUri").notNullable(); + tb.string("tokenEndpoint").notNullable(); + tb.string("userinfoEndpoint").notNullable(); + tb.text("encryptedClientId").notNullable(); + tb.string("clientIdIV").notNullable(); + tb.string("clientIdTag").notNullable(); + tb.text("encryptedClientSecret").notNullable(); + tb.string("clientSecretIV").notNullable(); + tb.string("clientSecretTag").notNullable(); tb.boolean("isActive").notNullable(); tb.timestamps(true, true, true); tb.uuid("orgId").notNullable().unique(); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5771cb669..cc7cea33c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -36,6 +36,7 @@ export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; +export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-memberships"; export * from "./org-roles"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 05dae70a3..b06114df6 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -9,17 +9,17 @@ import { TImmutableDBKeys } from "./models"; export const OidcConfigsSchema = z.object({ id: z.string().uuid(), - issuer: z.string().nullable().optional(), - authorizationEndpoint: z.string().nullable().optional(), - jwksUri: z.string().nullable().optional(), - tokenEndpoint: z.string().nullable().optional(), - userinfoEndpoint: z.string().nullable().optional(), - encryptedClientId: z.string().nullable().optional(), - clientIdIV: z.string().nullable().optional(), - clientIdTag: z.string().nullable().optional(), - encryptedClientSecret: z.string().nullable().optional(), - clientSecretIV: z.string().nullable().optional(), - clientSecretTag: z.string().nullable().optional(), + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + encryptedClientId: z.string(), + clientIdIV: z.string(), + clientIdTag: z.string(), + encryptedClientSecret: z.string(), + clientSecretIV: z.string(), + clientSecretTag: z.string(), isActive: z.boolean(), createdAt: z.date(), updatedAt: z.date(), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7d3155bd6..0bf4d0ae5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -101,6 +101,7 @@ import { integrationAuthServiceFactory } from "@app/services/integration-auth/in import { kmsDALFactory } from "@app/services/kms/kms-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { oidcConfigDALFactory } from "@app/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/services/oidc/oidc-config-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; @@ -241,6 +242,7 @@ export const registerRoutes = async ( const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + const oidcConfigDAL = oidcConfigDALFactory(db); const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); @@ -846,7 +848,10 @@ export const registerRoutes = async ( userAliasDAL, licenseService, tokenService, - smtpService + smtpService, + orgBotDAL, + permissionService, + oidcConfigDAL }); await superAdminService.initServerCfg(); diff --git a/backend/src/server/routes/v1/oidc-router.ts b/backend/src/server/routes/v1/oidc-router.ts index bccead8e9..05c2bd90e 100644 --- a/backend/src/server/routes/v1/oidc-router.ts +++ b/backend/src/server/routes/v1/oidc-router.ts @@ -12,6 +12,9 @@ import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; import { getConfig } from "@app/lib/config/env"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerOidcRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -101,6 +104,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, + isActive: true, orgId: true }).extend({ clientId: z.string(), @@ -122,4 +126,95 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { return oidc; } }); + + server.route({ + method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z + .object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean() + }) + .partial() + .merge(z.object({ orgSlug: z.string() })), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + handler: async (req) => { + const oidc = await server.services.oidc.updateOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); + + server.route({ + method: "POST", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + issuer: z.string(), + authorizationEndpoint: z.string(), + jwksUri: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + isActive: z.boolean(), + orgSlug: z.string() + }), + response: { + 200: OidcConfigsSchema.pick({ + id: true, + issuer: true, + authorizationEndpoint: true, + jwksUri: true, + tokenEndpoint: true, + userinfoEndpoint: true, + orgId: true, + isActive: true + }) + } + }, + + handler: async (req) => { + const oidc = await server.services.oidc.createOidcCfg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return oidc; + } + }); }; diff --git a/backend/src/services/oidc/oidc-config-types.ts b/backend/src/services/oidc/oidc-config-types.ts index d7145f453..24d3d0e4c 100644 --- a/backend/src/services/oidc/oidc-config-types.ts +++ b/backend/src/services/oidc/oidc-config-types.ts @@ -13,6 +13,18 @@ export type TGetOidcCfgDTO = { orgSlug: string; } & TGenericPermission; +export type TCreateOidcCfgDTO = { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; +} & TGenericPermission; + export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx new file mode 100644 index 000000000..44d976170 --- /dev/null +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { oidcConfigKeys } from "./queries"; + +export const useUpdateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer?: string; + authorizationEndpoint?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + clientId?: string; + clientSecret?: string; + isActive?: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.patch("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + orgSlug, + clientSecret, + isActive + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; + +export const useCreateOIDCConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }: { + issuer: string; + authorizationEndpoint: string; + jwksUri: string; + tokenEndpoint: string; + userinfoEndpoint: string; + clientId: string; + clientSecret: string; + isActive: boolean; + orgSlug: string; + }) => { + const { data } = await apiRequest.post("/api/v1/oidc/config", { + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive, + orgSlug + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(oidcConfigKeys.getOIDCConfig(dto.orgSlug)); + } + }); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx new file mode 100644 index 000000000..55b7924a0 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -0,0 +1,245 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addOIDC"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void; +}; + +const schema = z.object({ + issuer: z.string().min(1), + authorizationEndpoint: z.string().min(1), + jwksUri: z.string().min(1), + tokenEndpoint: z.string().min(1), + userinfoEndpoint: z.string().min(1), + clientId: z.string().min(1), + clientSecret: z.string().min(1) +}); + +export type OIDCFormData = z.infer; + +export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateOIDCConfig(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateOIDCConfig(); + const { data } = useGetOIDCConfig(currentOrg?.slug ?? ""); + + const { control, handleSubmit, reset, setValue } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (data) { + setValue("issuer", data.issuer); + setValue("authorizationEndpoint", data.authorizationEndpoint); + setValue("jwksUri", data.jwksUri); + setValue("tokenEndpoint", data.tokenEndpoint); + setValue("userinfoEndpoint", data.userinfoEndpoint); + setValue("clientId", data.clientId); + setValue("clientSecret", data.clientSecret); + } + }, [data]); + + const onOIDCModalSubmit = async ({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret + }: OIDCFormData) => { + try { + if (!currentOrg) return; + + if (!data) { + await createMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } else { + await updateMutateAsync({ + issuer, + authorizationEndpoint, + jwksUri, + tokenEndpoint, + userinfoEndpoint, + clientId, + clientSecret, + isActive: true, + orgSlug: currentOrg.slug + }); + } + + handlePopUpClose("addOIDC"); + + createNotification({ + text: `Successfully ${!data ? "added" : "updated"} OIDC SSO configuration`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${!data ? "add" : "update"} OIDC SSO configuration`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addOIDC", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 322798d16..31d6987bd 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -3,6 +3,7 @@ import { withPermission } from "@app/hoc"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; import { OrgLDAPSection } from "./OrgLDAPSection"; +import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; @@ -12,6 +13,7 @@ export const OrgAuthTab = withPermission(
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx new file mode 100644 index 000000000..41d7b7416 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgOIDCSection.tsx @@ -0,0 +1,101 @@ +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useGetOIDCConfig } from "@app/hooks/api"; +import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { OIDCModal } from "./OIDCModal"; + +export const OrgOIDCSection = (): JSX.Element => { + const { currentOrg } = useOrganization(); + + const { data, isLoading } = useGetOIDCConfig(currentOrg?.slug ?? ""); + const { mutateAsync } = useUpdateOIDCConfig(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addOIDC" + ] as const); + + const handleOIDCToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + + await mutateAsync({ + orgSlug: currentOrg?.slug, + isActive: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} OIDC SSO`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${value ? "enable" : "disable"} OIDC SSO`, + type: "error" + }); + } + }; + + const addOidcButtonClick = async () => { + try { + handlePopUpOpen("addOIDC"); + } catch (err) { + console.error(err); + } + }; + + return ( + <> +
+
+
+

OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + + )} + + )} +
+

Manage OIDC authentication configuration

+
+ {data && ( +
+
+

Enable OIDC

+ {!isLoading && ( + + {(isAllowed) => ( + handleOIDCToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed} + /> + )} + + )} +
+

+ Allow members to authenticate into Infisical with OIDC +

+
+ )} + + + ); +};