diff --git a/server/routers/external.ts b/server/routers/external.ts index cf7eac6..903885b 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -501,6 +501,12 @@ authenticated.put( idp.createOidcIdp ); +authenticated.post( + "/idp/:idpId/oidc", + verifyUserIsServerAdmin, + idp.updateOidcIdp +); + authenticated.delete( "/idp/:idpId", verifyUserIsServerAdmin, diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 1090b06..a8e7767 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -24,7 +24,7 @@ const bodySchema = z identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), - scopes: z.array(z.string().nonempty()), + scopes: z.string().nonempty(), autoProvision: z.boolean().optional() }) .strict(); @@ -104,7 +104,7 @@ export async function createOidcIdp( authUrl, tokenUrl, autoProvision, - scopes: JSON.stringify(scopes), + scopes, identifierPath, emailPath, namePath diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index e73d6b4..101a9d8 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -76,7 +76,14 @@ export async function generateOidcUrl( ); } - const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); + const parsedScopes = existingIdp.idpOidcConfig.scopes + .split(" ") + .map((scope) => { + return scope.trim(); + }) + .filter((scope) => { + return scope.length > 0; + }); const key = config.getRawConfig().server.secret; diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index b0b2825..731d39e 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -1,4 +1,5 @@ export * from "./createOidcIdp"; +export * from "./updateOidcIdp"; export * from "./deleteIdp"; export * from "./listIdps"; export * from "./generateOidcUrl"; diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts new file mode 100644 index 0000000..cf43cdf --- /dev/null +++ b/server/routers/idp/updateOidcIdp.ts @@ -0,0 +1,162 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOidcConfig } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z + .object({ + name: z.string().nonempty(), + clientId: z.string().nonempty(), + clientSecret: z.string().nonempty(), + authUrl: z.string().url(), + tokenUrl: z.string().url(), + identifierPath: z.string().nonempty(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().optional(), + autoProvision: z.boolean().optional() + }) + .strict(); + +export type UpdateIdpResponse = { + idpId: number; +}; + +registry.registerPath({ + method: "post", + path: "/idp/:idpId/oidc", + description: "Update an OIDC IdP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateOidcIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + const { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + name, + autoProvision + } = parsedBody.data; + + // Check if IDP exists and is of type OIDC + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + if (existingIdp.type !== "oidc") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP is not an OIDC provider" + ) + ); + } + + const key = config.getRawConfig().server.secret; + const encryptedSecret = encrypt(clientSecret, key); + const encryptedClientId = encrypt(clientId, key); + + await db.transaction(async (trx) => { + // Update IDP name + await trx + .update(idp) + .set({ + name + }) + .where(eq(idp.idpId, idpId)); + + // Update OIDC config + await trx + .update(idpOidcConfig) + .set({ + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + autoProvision, + scopes, + identifierPath, + emailPath, + namePath + }) + .where(eq(idpOidcConfig.idpId, idpId)); + }); + + return response(res, { + data: { + idpId + }, + success: true, + error: false, + message: "IdP updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx index 59387d7..0048c23 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { IdpDataTable } from "./AdminIdpDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; @@ -11,6 +11,14 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; +import { useRouter } from "next/navigation"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import Link from "next/link"; export type IdpRow = { idpId: number; @@ -27,6 +35,7 @@ export default function IdpTable({ idps }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedIdp, setSelectedIdp] = useState(null); const api = createApiClient(useEnvContext()); + const router = useRouter(); const deleteIdp = async (idpId: number) => { try { @@ -35,8 +44,7 @@ export default function IdpTable({ idps }: Props) { title: "Success", description: "Identity provider deleted successfully" }); - // Refresh the page to update the list - window.location.reload(); + router.refresh(); } catch (e) { toast({ title: "Error", @@ -56,6 +64,41 @@ export default function IdpTable({ idps }: Props) { }; const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const r = row.original; + + return ( + + + + + + + + View settings + + + { + setSelectedIdp(r); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, { accessorKey: "idpId", header: ({ column }) => { @@ -106,9 +149,7 @@ export default function IdpTable({ idps }: Props) { cell: ({ row }) => { const type = row.original.type; return ( - - {getTypeDisplay(type)} - + {getTypeDisplay(type)} ); } }, @@ -131,19 +172,15 @@ export default function IdpTable({ idps }: Props) { { id: "actions", cell: ({ row }) => { - const idp = row.original; + const siteRow = row.original; return (
- + + +
); } diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx new file mode 100644 index 0000000..72f8e2b --- /dev/null +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter, useParams, redirect } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter, + SettingsSectionGrid +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState, useEffect } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; + +const GeneralFormSchema = z.object({ + name: z.string().min(2, { message: "Name must be at least 2 characters." }), + clientId: z.string().min(1, { message: "Client ID is required." }), + clientSecret: z.string().min(1, { message: "Client Secret is required." }), + authUrl: z.string().url({ message: "Auth URL must be a valid URL." }), + tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }), + identifierPath: z + .string() + .min(1, { message: "Identifier Path is required." }), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().min(1, { message: "Scopes are required." }), + autoProvision: z.boolean().default(false) +}); + +type GeneralFormValues = z.infer; + +export default function GeneralPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { idpId } = useParams(); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + + const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: "", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + emailPath: "email", + namePath: "name", + scopes: "openid profile email", + autoProvision: true + } + }); + + useEffect(() => { + const loadIdp = async () => { + try { + const res = await api.get(`/idp/${idpId}`); + if (res.status === 200) { + const data = res.data.data; + form.reset({ + name: data.idp.name, + clientId: data.idpOidcConfig.clientId, + clientSecret: data.idpOidcConfig.clientSecret, + authUrl: data.idpOidcConfig.authUrl, + tokenUrl: data.idpOidcConfig.tokenUrl, + identifierPath: data.idpOidcConfig.identifierPath, + emailPath: data.idpOidcConfig.emailPath, + namePath: data.idpOidcConfig.namePath, + scopes: data.idpOidcConfig.scopes, + autoProvision: data.idpOidcConfig.autoProvision + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + router.push("/admin/idp"); + } finally { + setInitialLoading(false); + } + }; + + loadIdp(); + }, [idpId, api, form, router]); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + try { + const payload = { + name: data.name, + clientId: data.clientId, + clientSecret: data.clientSecret, + authUrl: data.authUrl, + tokenUrl: data.tokenUrl, + identifierPath: data.identifierPath, + emailPath: data.emailPath, + namePath: data.namePath, + autoProvision: data.autoProvision, + scopes: data.scopes + }; + + const res = await api.post(`/idp/${idpId}/oidc`, payload); + + if (res.status === 200) { + toast({ + title: "Success", + description: "Identity provider updated successfully" + }); + router.refresh(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + if (initialLoading) { + return null; + } + + return ( + + + + + General Information + + + Configure the basic information for your identity + provider + + + + + + Redirect URL + + + + + + + + + + About Redirect URL + + + This is the URL to which users will be redirected + after authentication. You need to configure this URL + in your identity provider settings. + + + +
+ + ( + + Name + + + + + A display name for this identity + provider + + + + )} + /> + + { + form.setValue("autoProvision", checked); + }} + /> + + When enabled, users will be automatically + created in the system upon first login using + this identity provider. + + + +
+
+
+ + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints and + credentials + + + + +
+ + ( + + Client ID + + + + + The OAuth2 client ID from + your identity provider + + + + )} + /> + + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity provider + + + + )} + /> + + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> + + ( + + Token URL + + + + + The OAuth2 token endpoint + URL + + + + )} + /> + + +
+
+
+ + + + + Token Configuration + + + Configure how to extract user information from the + ID token + + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath syntax + to extract values from the ID token. + + Learn more about JMESPath{" "} + + + + + + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the user's + email in the ID token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the user's + name in the ID token + + + + )} + /> + + ( + + Scopes + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+ + + + +
+
+
+ ); +} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx new file mode 100644 index 0000000..193cbe4 --- /dev/null +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -0,0 +1,57 @@ +import { internal } from "@app/lib/api"; +import { GetIdpResponse } from "@server/routers/idp"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ idpId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + + let idp = null; + try { + const res = await internal.get>( + `/idp/${params.idpId}`, + await authCookieHeader() + ); + idp = res.data.data; + } catch { + redirect("/admin/idp"); + } + + const navItems = [ + { + title: "General", + href: `/admin/idp/${params.idpId}/general` + } + ]; + + return ( + <> + + +
+ {children} +
+ + ); +} diff --git a/src/app/admin/idp/[idpId]/page.tsx b/src/app/admin/idp/[idpId]/page.tsx new file mode 100644 index 0000000..a8701e7 --- /dev/null +++ b/src/app/admin/idp/[idpId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function IdpPage(props: { + params: Promise<{ idpId: string }>; +}) { + const params = await props.params; + redirect(`/admin/idp/${params.idpId}/general`); +} diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 74567dc..1d695f4 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -105,7 +105,7 @@ export default function Page() { emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - scopes: data.scopes.split(" ").filter(Boolean) + scopes: data.scopes }; const res = await api.put("/idp/oidc", payload); @@ -115,7 +115,7 @@ export default function Page() { title: "Success", description: "Identity provider created successfully" }); - router.push("/admin/idp"); + router.push(`/admin/idp/${res.data.data.idpId}`); } } catch (e) { toast({