diff --git a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts index b74f0040a..3f4b0636d 100644 --- a/backend/src/db/migrations/20240617041053_add-oidc-auth.ts +++ b/backend/src/db/migrations/20240617041053_add-oidc-auth.ts @@ -6,12 +6,14 @@ 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").notNullable(); - tb.string("authorizationEndpoint").notNullable(); - tb.string("jwksUri").notNullable(); - tb.string("tokenEndpoint").notNullable(); - tb.string("userinfoEndpoint").notNullable(); + tb.string("discoveryURL"); + tb.string("issuer"); + tb.string("authorizationEndpoint"); + tb.string("jwksUri"); + tb.string("tokenEndpoint"); + tb.string("userinfoEndpoint"); tb.text("encryptedClientId").notNullable(); + tb.string("configurationType").notNullable(); tb.string("clientIdIV").notNullable(); tb.string("clientIdTag").notNullable(); tb.text("encryptedClientSecret").notNullable(); diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 3de22d1d0..d78f9e9a7 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -9,12 +9,14 @@ import { TImmutableDBKeys } from "./models"; export const OidcConfigsSchema = z.object({ id: z.string().uuid(), - issuer: z.string(), - authorizationEndpoint: z.string(), - jwksUri: z.string(), - tokenEndpoint: z.string(), - userinfoEndpoint: z.string(), + discoveryURL: z.string().nullable().optional(), + 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(), + configurationType: z.string(), clientIdIV: z.string(), clientIdTag: z.string(), encryptedClientSecret: z.string(), diff --git a/backend/src/ee/routes/v1/oidc-router.ts b/backend/src/ee/routes/v1/oidc-router.ts index d2c13039d..73ce870eb 100644 --- a/backend/src/ee/routes/v1/oidc-router.ts +++ b/backend/src/ee/routes/v1/oidc-router.ts @@ -13,6 +13,7 @@ import { Redis } from "ioredis"; import { z } from "zod"; import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs"; +import { OIDCConfigurationType } from "@app/ee/services/oidc/oidc-config-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -140,6 +141,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, + configurationType: true, + discoveryURL: true, isActive: true, orgId: true, allowedEmailDomains: true @@ -187,22 +190,25 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { .map((id) => id.trim()) .join(", "); }), - issuer: z.string().trim(), - authorizationEndpoint: z.string().trim(), - jwksUri: z.string().trim(), - tokenEndpoint: z.string().trim(), - userinfoEndpoint: z.string().trim(), + discoveryURL: z.string().trim().optional().default(""), + issuer: z.string().trim().optional().default(""), + authorizationEndpoint: z.string().trim().optional().default(""), + jwksUri: z.string().trim().optional().default(""), + tokenEndpoint: z.string().trim().optional().default(""), + userinfoEndpoint: z.string().trim().optional().default(""), clientId: z.string().trim(), clientSecret: z.string().trim(), isActive: z.boolean() }) .partial() - .merge(z.object({ orgSlug: z.string() })), + .merge(z.object({ orgSlug: z.string(), configurationType: z.nativeEnum(OIDCConfigurationType) })), response: { 200: OidcConfigsSchema.pick({ id: true, issuer: true, authorizationEndpoint: true, + configurationType: true, + discoveryURL: true, jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, @@ -233,7 +239,6 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ - issuer: z.string().trim(), allowedEmailDomains: z .string() .trim() @@ -247,10 +252,13 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { .map((id) => id.trim()) .join(", "); }), - authorizationEndpoint: z.string().trim(), - jwksUri: z.string().trim(), - tokenEndpoint: z.string().trim(), - userinfoEndpoint: z.string().trim(), + configurationType: z.nativeEnum(OIDCConfigurationType), + issuer: z.string().trim().optional().default(""), + discoveryURL: z.string().trim().optional().default(""), + authorizationEndpoint: z.string().trim().optional().default(""), + jwksUri: z.string().trim().optional().default(""), + tokenEndpoint: z.string().trim().optional().default(""), + userinfoEndpoint: z.string().trim().optional().default(""), clientId: z.string().trim(), clientSecret: z.string().trim(), isActive: z.boolean(), @@ -261,6 +269,8 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => { id: true, issuer: true, authorizationEndpoint: true, + configurationType: true, + discoveryURL: true, jwksUri: true, tokenEndpoint: true, userinfoEndpoint: true, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 546de7d9d..81f71ed7e 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; -import { Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; +import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; @@ -32,7 +32,13 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TOidcConfigDALFactory } from "./oidc-config-dal"; -import { TCreateOidcCfgDTO, TGetOidcCfgDTO, TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; +import { + OIDCConfigurationType, + TCreateOidcCfgDTO, + TGetOidcCfgDTO, + TOidcLoginDTO, + TUpdateOidcCfgDTO +} from "./oidc-config-types"; type TOidcConfigServiceFactoryDep = { userDAL: Pick; @@ -133,6 +139,8 @@ export const oidcConfigServiceFactory = ({ id: oidcCfg.id, issuer: oidcCfg.issuer, authorizationEndpoint: oidcCfg.authorizationEndpoint, + configurationType: oidcCfg.configurationType, + discoveryURL: oidcCfg.discoveryURL, jwksUri: oidcCfg.jwksUri, tokenEndpoint: oidcCfg.tokenEndpoint, userinfoEndpoint: oidcCfg.userinfoEndpoint, @@ -313,6 +321,8 @@ export const oidcConfigServiceFactory = ({ const updateOidcCfg = async ({ orgSlug, allowedEmailDomains, + configurationType, + discoveryURL, actor, actorOrgId, actorAuthMethod, @@ -363,6 +373,8 @@ export const oidcConfigServiceFactory = ({ const updateQuery: TOidcConfigsUpdate = { allowedEmailDomains, + configurationType, + discoveryURL, issuer, authorizationEndpoint, tokenEndpoint, @@ -397,6 +409,8 @@ export const oidcConfigServiceFactory = ({ const createOidcCfg = async ({ orgSlug, allowedEmailDomains, + configurationType, + discoveryURL, actor, actorOrgId, actorAuthMethod, @@ -493,6 +507,8 @@ export const oidcConfigServiceFactory = ({ const oidcCfg = await oidcConfigDAL.create({ issuer, isActive, + configurationType, + discoveryURL, authorizationEndpoint, allowedEmailDomains, jwksUri, @@ -534,15 +550,36 @@ export const oidcConfigServiceFactory = ({ }); } - const openIdIssuer = new OpenIdIssuer({ - issuer: oidcCfg.issuer, - authorization_endpoint: oidcCfg.authorizationEndpoint, - jwks_uri: oidcCfg.jwksUri, - token_endpoint: oidcCfg.tokenEndpoint, - userinfo_endpoint: oidcCfg.userinfoEndpoint - }); + let issuer: Issuer; + if (oidcCfg.configurationType === OIDCConfigurationType.DISCOVERY_URL) { + if (!oidcCfg.discoveryURL) { + throw new BadRequestError({ + message: "OIDC not configured correctly" + }); + } + issuer = await Issuer.discover(oidcCfg.discoveryURL); + } else { + if ( + !oidcCfg.issuer || + !oidcCfg.authorizationEndpoint || + !oidcCfg.jwksUri || + !oidcCfg.tokenEndpoint || + !oidcCfg.userinfoEndpoint + ) { + throw new BadRequestError({ + message: "OIDC not configured correctly" + }); + } + issuer = new OpenIdIssuer({ + issuer: oidcCfg.issuer, + authorization_endpoint: oidcCfg.authorizationEndpoint, + jwks_uri: oidcCfg.jwksUri, + token_endpoint: oidcCfg.tokenEndpoint, + userinfo_endpoint: oidcCfg.userinfoEndpoint + }); + } - const client = new openIdIssuer.Client({ + const client = new issuer.Client({ client_id: oidcCfg.clientId, client_secret: oidcCfg.clientSecret, redirect_uris: [`${appCfg.SITE_URL}/api/v1/sso/oidc/callback`] diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index f773bbe92..f288ccc2b 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -1,5 +1,10 @@ import { TGenericPermission } from "@app/lib/types"; +export enum OIDCConfigurationType { + CUSTOM = "custom", + DISCOVERY_URL = "discoveryURL" +} + export type TOidcLoginDTO = { externalId: string; email: string; @@ -20,12 +25,14 @@ export type TGetOidcCfgDTO = }; export type TCreateOidcCfgDTO = { - issuer: string; - authorizationEndpoint: string; - allowedEmailDomains: string; - jwksUri: string; - tokenEndpoint: string; - userinfoEndpoint: string; + issuer?: string; + authorizationEndpoint?: string; + discoveryURL?: string; + configurationType: OIDCConfigurationType; + allowedEmailDomains?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; clientId: string; clientSecret: string; isActive: boolean; @@ -36,6 +43,7 @@ export type TUpdateOidcCfgDTO = Partial<{ issuer: string; authorizationEndpoint: string; allowedEmailDomains: string; + discoveryURL: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; @@ -43,5 +51,6 @@ export type TUpdateOidcCfgDTO = Partial<{ clientSecret: string; isActive: boolean; orgSlug: string; -}> & - TGenericPermission; +}> & { + configurationType: OIDCConfigurationType; +} & TGenericPermission; diff --git a/frontend/src/hooks/api/oidcConfig/mutations.tsx b/frontend/src/hooks/api/oidcConfig/mutations.tsx index 39467e84c..d20f4fb3b 100644 --- a/frontend/src/hooks/api/oidcConfig/mutations.tsx +++ b/frontend/src/hooks/api/oidcConfig/mutations.tsx @@ -10,6 +10,8 @@ export const useUpdateOIDCConfig = () => { mutationFn: async ({ issuer, authorizationEndpoint, + configurationType, + discoveryURL, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -22,18 +24,22 @@ export const useUpdateOIDCConfig = () => { allowedEmailDomains?: string; issuer?: string; authorizationEndpoint?: string; + discoveryURL?: string; jwksUri?: string; tokenEndpoint?: string; userinfoEndpoint?: string; clientId?: string; clientSecret?: string; isActive?: boolean; + configurationType: string; orgSlug: string; }) => { const { data } = await apiRequest.patch("/api/v1/sso/oidc/config", { issuer, allowedEmailDomains, authorizationEndpoint, + discoveryURL, + configurationType, jwksUri, tokenEndpoint, userinfoEndpoint, @@ -56,6 +62,8 @@ export const useCreateOIDCConfig = () => { return useMutation({ mutationFn: async ({ issuer, + configurationType, + discoveryURL, authorizationEndpoint, allowedEmailDomains, jwksUri, @@ -66,11 +74,13 @@ export const useCreateOIDCConfig = () => { isActive, orgSlug }: { - issuer: string; - authorizationEndpoint: string; - jwksUri: string; - tokenEndpoint: string; - userinfoEndpoint: string; + issuer?: string; + configurationType: string; + discoveryURL?: string; + authorizationEndpoint?: string; + jwksUri?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; clientId: string; clientSecret: string; isActive: boolean; @@ -79,6 +89,8 @@ export const useCreateOIDCConfig = () => { }) => { const { data } = await apiRequest.post("/api/v1/sso/oidc/config", { issuer, + configurationType, + discoveryURL, authorizationEndpoint, allowedEmailDomains, jwksUri, diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 457a86cb7..1b8ede5e3 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -2,6 +2,8 @@ export type OIDCConfigData = { id: string; issuer: string; authorizationEndpoint: string; + configurationType: string; + discoveryURL: string; jwksUri: string; tokenEndpoint: string; userinfoEndpoint: string; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx index 229f437f9..85a01cb9a 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -4,24 +4,42 @@ 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 { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} 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"; +enum ConfigurationType { + CUSTOM = "custom", + DISCOVERY_URL = "discoveryURL" +} + type Props = { popUp: UsePopUpState<["addOIDC"]>; - handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC", "loadViaDiscoveryURL"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addOIDC", "loadViaDiscoveryURL"]>, + 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), + configurationType: z.string(), + issuer: z.string().optional(), + discoveryURL: z.string().optional(), + authorizationEndpoint: z.string().optional(), + jwksUri: z.string().optional(), + tokenEndpoint: z.string().optional(), + userinfoEndpoint: z.string().optional(), clientId: z.string().min(1), clientSecret: z.string().min(1), allowedEmailDomains: z.string().optional() @@ -36,10 +54,15 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateOIDCConfig(); const { data } = useGetOIDCConfig(currentOrg?.slug ?? ""); - const { control, handleSubmit, reset, setValue } = useForm({ - resolver: zodResolver(schema) + const { control, handleSubmit, reset, setValue, watch } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + configurationType: ConfigurationType.DISCOVERY_URL + } }); + const configurationTypeValue = watch("configurationType"); + useEffect(() => { if (data) { setValue("issuer", data.issuer); @@ -47,9 +70,11 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) setValue("jwksUri", data.jwksUri); setValue("tokenEndpoint", data.tokenEndpoint); setValue("userinfoEndpoint", data.userinfoEndpoint); + setValue("discoveryURL", data.discoveryURL); setValue("clientId", data.clientId); setValue("clientSecret", data.clientSecret); setValue("allowedEmailDomains", data.allowedEmailDomains); + setValue("configurationType", data.configurationType); } }, [data]); @@ -60,6 +85,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) jwksUri, tokenEndpoint, userinfoEndpoint, + configurationType, + discoveryURL, clientId, clientSecret }: OIDCFormData) => { @@ -69,6 +96,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) if (!data) { await createMutateAsync({ issuer, + configurationType, + discoveryURL, authorizationEndpoint, allowedEmailDomains, jwksUri, @@ -82,6 +111,8 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) } else { await updateMutateAsync({ issuer, + configurationType, + discoveryURL, authorizationEndpoint, allowedEmailDomains, jwksUri, @@ -121,77 +152,125 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
( - - - - )} - /> - ( + name="configurationType" + render={({ field: { onChange, ...field }, fieldState: { error } }) => ( - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - + onValueChange={(e) => onChange(e)} + > + Discovery URL + Custom + )} /> + {configurationTypeValue === ConfigurationType.DISCOVERY_URL && ( + ( + + + + )} + /> + )} + {configurationTypeValue === ConfigurationType.CUSTOM && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + )}