add create idp wizard for oidc

This commit is contained in:
miloschwartz 2025-04-16 22:39:24 -04:00
parent 334fc55dd0
commit 189b739997
No known key found for this signature in database
9 changed files with 834 additions and 36 deletions

View file

@ -4,7 +4,9 @@ const ALGORITHM = "aes-256-gcm";
export function encrypt(value: string, key: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
@ -25,8 +27,9 @@ export function decrypt(encryptedValue: string, key: string): string {
const iv = Buffer.from(ivB64, "base64");
const encrypted = Buffer.from(encryptedB64, "base64");
const authTag = Buffer.from(authTagB64, "base64");
const keyBuffer = Buffer.from(key, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { domains, idp, orgDomains, users } from "@server/db/schemas";
import { domains, idp, orgDomains, users, idpOrg } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -28,13 +28,33 @@ const querySchema = z
.strict();
async function query(limit: number, offset: number) {
const res = await db.select().from(orgDomains).limit(limit).offset(offset);
const res = await db
.select({
idpId: idp.idpId,
name: idp.name,
type: idp.type,
orgCount: sql<number>`count(${idpOrg.orgId})`
})
.from(idp)
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
.groupBy(idp.idpId)
.limit(limit)
.offset(offset);
return res;
}
export type ListIdpResponse = {
idps: NonNullable<Awaited<ReturnType<typeof query>>>;
pagination: { total: number; limit: number; offset: number };
export type ListIdpsResponse = {
idps: Array<{
idpId: number;
name: string;
type: string;
orgCount: number;
}>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
registry.registerPath({
@ -71,7 +91,7 @@ export async function listIdps(
.select({ count: sql<number>`count(*)` })
.from(idp);
return response<ListIdpResponse>(res, {
return response<ListIdpsResponse>(res, {
data: {
idps: list,
pagination: {
@ -82,7 +102,7 @@ export async function listIdps(
},
success: true,
error: false,
message: "Users retrieved successfully",
message: "Idps retrieved successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -0,0 +1,31 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useRouter } from "next/navigation";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function IdpDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const router = useRouter();
return (
<DataTable
columns={columns}
data={data}
title="Identity Providers"
searchPlaceholder="Search identity providers..."
searchColumn="name"
addButtonText="Add Identity Provider"
onAdd={() => {
router.push("/admin/idp/create");
}}
/>
);
}

View file

@ -0,0 +1,192 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { IdpDataTable } from "./AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
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";
export type IdpRow = {
idpId: number;
name: string;
type: string;
orgCount: number;
};
type Props = {
idps: IdpRow[];
};
export default function IdpTable({ idps }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/idp/${idpId}`);
toast({
title: "Success",
description: "Identity provider deleted successfully"
});
// Refresh the page to update the list
window.location.reload();
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "oidc":
return "OAuth2/OIDC";
default:
return type;
}
};
const columns: ColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">
{getTypeDisplay(type)}
</Badge>
);
}
},
{
accessorKey: "orgCount",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Policies
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const idp = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant="outline"
className="ml-2"
onClick={() => {
setSelectedIdp(idp);
setIsDeleteModalOpen(true);
}}
>
Delete
</Button>
</div>
);
}
}
];
return (
<>
{selectedIdp && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to permanently delete the
identity provider <b>{selectedIdp.name}</b>?
</p>
<p>
<b>
This will remove the identity provider and
all associated configurations. Users who
authenticate through this provider will no
longer be able to log in.
</b>
</p>
<p>
To confirm, please type the name of the identity
provider below.
</p>
</div>
}
buttonText="Confirm Delete Identity Provider"
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title="Delete Identity Provider"
/>
)}
<IdpDataTable columns={columns} data={idps} />
</>
);
}

View file

@ -0,0 +1,519 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
type: z.enum(["oidc"]),
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 CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc";
title: string;
description: string;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: "Configure an OpenID Connect identity provider"
}
];
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
autoProvision: true
}
});
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(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.split(" ").filter(Boolean)
};
const res = await api.put("/idp/oidc", payload);
if (res.status === 201) {
toast({
title: "Success",
description: "Identity provider created successfully"
});
router.push("/admin/idp");
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setCreateLoading(false);
}
}
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Create Identity Provider"
description="Configure a new identity provider for user authentication"
/>
<Button
variant="outline"
onClick={() => {
router.push("/admin/idp");
}}
>
See All Identity Providers
</Button>
</div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this
identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login using this identity
provider.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Provider Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the type of identity provider you want to
configure
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "oidc" && (
<>
<div className="grid md:grid-cols-2 gap-6">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider
endpoints and credentials
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
Client ID
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client
secret from your
identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2
authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Token URL
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 token
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Important Information
</AlertTitle>
<AlertDescription>
After creating the identity
provider, you will need to configure
the callback URL in your identity
provider's settings. The callback
URL will be provided after
successful creation.
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user
information from the ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath
syntax to extract values
from the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about
JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user identifier in
the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path
(Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's email in the
ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's name in the
ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
Scopes
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list
of OAuth2 scopes to
request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</div>
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push("/admin/idp");
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={createLoading}
loading={createLoading}
onClick={form.handleSubmit(onSubmit)}
>
Create Identity Provider
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,28 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "./AdminIdpTable";
export default async function IdpPage() {
let idps: IdpRow[] = [];
try {
const res = await internal.get<AxiosResponse<{ idps: IdpRow[] }>>(
`/idp`,
await authCookieHeader()
);
idps = res.data.data.idps;
} catch (e) {
console.error(e);
}
return (
<>
<SettingsSectionTitle
title="Manage Identity Providers"
description="View and manage identity providers in the system"
/>
<IdpTable idps={idps} />
</>
);
}

View file

@ -5,7 +5,8 @@ import {
Users,
Link as LinkIcon,
Waypoints,
Combine
Combine,
Fingerprint
} from "lucide-react";
export const rootNavItems: SidebarNavItem[] = [
@ -66,5 +67,10 @@ export const adminNavItems: SidebarNavItem[] = [
title: "All Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
},
{
title: "Identity Providers",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
}
];

View file

@ -18,30 +18,30 @@ export function Breadcrumbs() {
const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = segment;
// Format labels
if (segment === "settings") {
label = "Settings";
} else if (segment === "sites") {
label = "Sites";
} else if (segment === "resources") {
label = "Resources";
} else if (segment === "access") {
label = "Access Control";
} else if (segment === "general") {
label = "General";
} else if (segment === "share-links") {
label = "Shareable Links";
} else if (segment === "users") {
label = "Users";
} else if (segment === "roles") {
label = "Roles";
} else if (segment === "invitations") {
label = "Invitations";
} else if (segment === "connectivity") {
label = "Connectivity";
} else if (segment === "authentication") {
label = "Authentication";
}
// // Format labels
// if (segment === "settings") {
// label = "Settings";
// } else if (segment === "sites") {
// label = "Sites";
// } else if (segment === "resources") {
// label = "Resources";
// } else if (segment === "access") {
// label = "Access Control";
// } else if (segment === "general") {
// label = "General";
// } else if (segment === "share-links") {
// label = "Shareable Links";
// } else if (segment === "users") {
// label = "Users";
// } else if (segment === "roles") {
// label = "Roles";
// } else if (segment === "invitations") {
// label = "Invitations";
// } else if (segment === "connectivity") {
// label = "Connectivity";
// } else if (segment === "authentication") {
// label = "Authentication";
// }
return { label, href };
});

View file

@ -106,8 +106,7 @@ export function SidebarNav({
<div
className={cn(
"flex items-center w-full transition-colors rounded-md",
isActive && level === 0 && "bg-primary/10",
"group-hover:bg-muted"
isActive && level === 0 && "bg-primary/10"
)}
>
<Link