feat: finished oidc form functions

This commit is contained in:
Sheen Capadngan
2024-06-17 22:37:23 +08:00
parent 66e5edcfc0
commit 61fcb2b605
11 changed files with 580 additions and 24 deletions

View File

@@ -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 {

View File

@@ -6,17 +6,17 @@ export async function up(knex: Knex): Promise<void> {
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();

View File

@@ -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";

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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;
}
});
};

View File

@@ -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;

View File

@@ -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));
}
});
};

View File

@@ -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<typeof schema>;
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<OIDCFormData>({
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 (
<Modal
isOpen={popUp?.addOIDC?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addOIDC", isOpen);
reset();
}}
>
<ModalContent title="Manage OIDC configuration">
<form onSubmit={handleSubmit(onOIDCModalSubmit)}>
<Controller
control={control}
name="issuer"
render={({ field, fieldState: { error } }) => (
<FormControl label="Issuer" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="https://accounts.google.com" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="authorizationEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authorization Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://accounts.google.com/o/oauth2/v2/auth"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="tokenEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Token Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://oauth2.googleapis.com/token"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="userinfoEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User info endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://openidconnect.googleapis.com/v1/userinfo"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="jwksUri"
render={({ field, fieldState: { error } }) => (
<FormControl label="JWKS URI" errorText={error?.message} isError={Boolean(error)}>
<Input
{...field}
placeholder="https://www.googleapis.com/oauth2/v3/certs"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Client ID" errorText={error?.message} isError={Boolean(error)}>
<Input
placeholder="Client ID"
type="password"
autoComplete="off"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
placeholder="Client Secret"
type="password"
autoComplete="off"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addOIDC")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -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(
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgGeneralAuthSection />
<OrgSSOSection />
<OrgOIDCSection />
<OrgLDAPSection />
<OrgScimSection />
</div>

View File

@@ -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 (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">OIDC</h2>
{!isLoading && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button
onClick={addOidcButtonClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Manage OIDC authentication configuration</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable OIDC</h2>
{!isLoading && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-oidc-sso"
onCheckedChange={(value) => handleOIDCToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with OIDC
</p>
</div>
)}
<OIDCModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
);
};