diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index a7e85abb0..f3b4dba29 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -17,6 +17,17 @@ export enum LdapCredentialType { Static = "static" } +export enum TotpConfigType { + URL = "url", + MANUAL = "manual" +} + +export enum TotpAlgorithm { + SHA1 = "sha1", + SHA256 = "sha256", + SHA512 = "sha512" +} + export const DynamicSecretRedisDBSchema = z.object({ host: z.string().trim().toLowerCase(), port: z.number(), @@ -221,9 +232,29 @@ export const LdapSchema = z.union([ }) ]); -export const DynamicSecretTotpSchema = z.object({ - url: z.string().url().trim().min(1) -}); +export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ + z.object({ + configType: z.literal(TotpConfigType.URL), + url: z + .string() + .url() + .trim() + .min(1) + .refine((val) => { + const urlObj = new URL(val); + const secret = urlObj.searchParams.get("secret"); + + return Boolean(secret); + }, "OTP URL must contain secret field") + }), + z.object({ + configType: z.literal(TotpConfigType.MANUAL), + secret: z.string().min(1), + period: z.number().optional(), + algorithm: z.nativeEnum(TotpAlgorithm).optional(), + digits: z.number().optional() + }) +]); export enum DynamicSecretProviders { SqlDatabase = "sql-database", diff --git a/backend/src/ee/services/dynamic-secret/providers/totp.ts b/backend/src/ee/services/dynamic-secret/providers/totp.ts index d985d2125..4dec9abd1 100644 --- a/backend/src/ee/services/dynamic-secret/providers/totp.ts +++ b/backend/src/ee/services/dynamic-secret/providers/totp.ts @@ -4,20 +4,12 @@ import { HashAlgorithms } from "otplib/core"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { DynamicSecretTotpSchema, TDynamicProviderFns } from "./models"; +import { DynamicSecretTotpSchema, TDynamicProviderFns, TotpConfigType } from "./models"; export const TotpProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretTotpSchema.parseAsync(inputs); - const urlObj = new URL(providerInputs.url); - const secret = urlObj.searchParams.get("secret"); - if (!secret) { - throw new BadRequestError({ - message: "TOTP secret is missing from URL" - }); - } - return providerInputs; }; @@ -31,22 +23,46 @@ export const TotpProvider = (): TDynamicProviderFns => { const entityId = alphaNumericNanoId(32); const authenticatorInstance = authenticator.clone(); - const urlObj = new URL(providerInputs.url); - const secret = urlObj.searchParams.get("secret") as string; - const periodFromUrl = urlObj.searchParams.get("period"); - const digitsFromUrl = urlObj.searchParams.get("digits"); - const algorithm = urlObj.searchParams.get("algorithm"); + let secret: string; + let period: number | null | undefined; + let digits: number | null | undefined; + let algorithm: HashAlgorithms | null | undefined; - if (digitsFromUrl) { - authenticatorInstance.options = { digits: +digitsFromUrl }; + if (providerInputs.configType === TotpConfigType.URL) { + const urlObj = new URL(providerInputs.url); + secret = urlObj.searchParams.get("secret") as string; + const periodFromUrl = urlObj.searchParams.get("period"); + const digitsFromUrl = urlObj.searchParams.get("digits"); + const algorithmFromUrl = urlObj.searchParams.get("algorithm"); + + if (periodFromUrl) { + period = +periodFromUrl; + } + + if (digitsFromUrl) { + digits = +digitsFromUrl; + } + + if (algorithmFromUrl) { + algorithm = algorithmFromUrl.toLowerCase() as HashAlgorithms; + } + } else { + secret = providerInputs.secret; + period = providerInputs.period; + digits = providerInputs.digits; + algorithm = providerInputs.algorithm as unknown as HashAlgorithms; + } + + if (digits) { + authenticatorInstance.options = { digits }; } if (algorithm) { - authenticatorInstance.options = { algorithm: algorithm.toLowerCase() as HashAlgorithms }; + authenticatorInstance.options = { algorithm }; } - if (periodFromUrl) { - authenticatorInstance.options = { step: +periodFromUrl }; + if (period) { + authenticatorInstance.options = { step: period }; } return { entityId, data: { TOTP: authenticatorInstance.generate(secret) } }; diff --git a/docs/documentation/platform/dynamic-secrets/totp.mdx b/docs/documentation/platform/dynamic-secrets/totp.mdx index 4445bd670..82dc9501d 100644 --- a/docs/documentation/platform/dynamic-secrets/totp.mdx +++ b/docs/documentation/platform/dynamic-secrets/totp.mdx @@ -22,23 +22,34 @@ The Infisical TOTP dynamic secret allows you to generate time-based one-time pas ![Dynamic Secret Modal](/images/platform/dynamic-secrets/dynamic-secret-modal-totp.png) - - Name by which you want the secret to be referenced - + + Name by which you want the secret to be referenced + + + There are two supported configuration types - `url` and `manual`. - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + When `url` is selected, you can configure the TOTP generator using the OTP URL. + + When `manual` is selected, you can configure the TOTP generator using the secret key along with other configurations like period, number of digits, and algorithm. + + + OTP URL in `otpauth://` format used to generate TOTP codes. + + + Base32 encoded secret used to generate TOTP codes. + + + Time interval in seconds between generating new TOTP codes. + + + Number of digits to generate in each TOTP code. + + + Hash algorithm to use when generating TOTP codes. The supported algorithms are sha1, sha256, and sha512. - - Maximum time-to-live for a generated secret - - - - OTP url from the TOTP provider - - - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-url.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-manual.png) diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-manual.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-manual.png new file mode 100644 index 000000000..cdb82b8dd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-manual.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-url.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-url.png new file mode 100644 index 000000000..ede474a59 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp-url.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp.png deleted file mode 100644 index babfb6ba0..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-totp.png and /dev/null differ diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 14c86c4d8..35b08df32 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -234,9 +234,18 @@ export type TDynamicSecretProvider = } | { type: DynamicSecretProviders.Totp; - inputs: { - url: string; - }; + inputs: + | { + configType: "url"; + url: string; + } + | { + configType: "manual"; + secret: string; + period?: number; + algorithm?: string; + digits?: number; + }; }; export type TCreateDynamicSecretDTO = { projectSlug: string; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx index fc3d841a0..58e30d4ea 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx @@ -6,20 +6,52 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +enum ConfigType { + URL = "url", + MANUAL = "manual" +} + +enum TotpAlgorithm { + SHA1 = "sha1", + SHA256 = "sha256", + SHA512 = "sha512" +} + const formSchema = z.object({ - provider: z.object({ - url: z.string().url().trim().min(1) - }), + provider: z.discriminatedUnion("configType", [ + z.object({ + configType: z.literal(ConfigType.URL), + url: z + .string() + .url() + .trim() + .min(1) + .refine((val) => { + const urlObj = new URL(val); + const secret = urlObj.searchParams.get("secret"); + + return Boolean(secret); + }, "OTP URL must contain secret field") + }), + z.object({ + configType: z.literal(ConfigType.MANUAL), + secret: z.string().min(1), + period: z.number().optional(), + algorithm: z.nativeEnum(TotpAlgorithm).optional(), + digits: z.number().optional() + }) + ]), name: z .string() .trim() .min(1) .refine((val) => val.toLowerCase() === val, "Must be lowercase") }); + type TForm = z.infer; type Props = { @@ -39,12 +71,20 @@ export const TotpInputForm = ({ }: Props) => { const { control, + watch, formState: { isSubmitting }, handleSubmit } = useForm({ - resolver: zodResolver(formSchema) + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + configType: ConfigType.URL + } + } }); + const selectedConfigType = watch("provider.configType"); + const createDynamicSecret = useCreateDynamicSecret(); const handleCreateDynamicSecret = async ({ name, provider }: TForm) => { @@ -113,19 +153,142 @@ export const TotpInputForm = ({
( - + )} /> + {selectedConfigType === ConfigType.URL && ( + ( + + + + )} + /> + )} + {selectedConfigType === ConfigType.MANUAL && ( + <> + ( + + + + )} + /> +
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + + + )} + /> +
+ + )}
diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx index 054f66c3b..8a9127e89 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx @@ -6,16 +6,47 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { useUpdateDynamicSecret } from "@app/hooks/api"; import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +enum ConfigType { + URL = "url", + MANUAL = "manual" +} + +enum TotpAlgorithm { + SHA1 = "sha1", + SHA256 = "sha256", + SHA512 = "sha512" +} + const formSchema = z.object({ inputs: z - .object({ - url: z.string().url().trim().min(1) - }) - .partial(), + .discriminatedUnion("configType", [ + z.object({ + configType: z.literal(ConfigType.URL), + url: z + .string() + .url() + .trim() + .min(1) + .refine((val) => { + const urlObj = new URL(val); + const secret = urlObj.searchParams.get("secret"); + + return Boolean(secret); + }, "OTP URL must contain secret field") + }), + z.object({ + configType: z.literal(ConfigType.MANUAL), + secret: z.string().min(1), + period: z.number().optional(), + algorithm: z.nativeEnum(TotpAlgorithm).optional(), + digits: z.number().optional() + }) + ]) + .optional(), newName: z .string() .trim() @@ -42,17 +73,17 @@ export const EditDynamicSecretTotpForm = ({ const { control, formState: { isSubmitting }, + watch, handleSubmit } = useForm({ resolver: zodResolver(formSchema), values: { newName: dynamicSecret.name, - inputs: { - ...(dynamicSecret.inputs as TForm["inputs"]) - } + inputs: dynamicSecret.inputs as TForm["inputs"] } }); + const selectedConfigType = watch("inputs.configType"); const updateDynamicSecret = useUpdateDynamicSecret(); const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => { @@ -126,19 +157,142 @@ export const EditDynamicSecretTotpForm = ({
( - + )} /> + {selectedConfigType === ConfigType.URL && ( + ( + + + + )} + /> + )} + {selectedConfigType === ConfigType.MANUAL && ( + <> + ( + + + + )} + /> +
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + + + )} + /> +
+ + )}