From b80b77ec367ba8585e1e8ca4a0243b13b3d1191e Mon Sep 17 00:00:00 2001
From: =
Date: Tue, 24 Jun 2025 16:46:46 +0530
Subject: [PATCH 01/15] feat: completed backend changes for tls auth
---
backend/src/@types/fastify.d.ts | 2 +
backend/src/@types/knex.d.ts | 8 +
.../20250624061429_identity-tls-auth.ts | 28 ++
.../src/db/schemas/identity-tls-cert-auths.ts | 27 ++
backend/src/db/schemas/index.ts | 1 +
backend/src/db/schemas/models.ts | 2 +
.../ee/services/audit-log/audit-log-types.ts | 58 +++
backend/src/lib/api-docs/constants.ts | 33 ++
backend/src/server/routes/index.ts | 13 +
.../v1/identity-tls-cert-auth-router.ts | 345 +++++++++++++++
backend/src/server/routes/v1/index.ts | 2 +
.../identity-access-token-dal.ts | 9 +-
.../identity-access-token-service.ts | 3 +-
.../identity-tls-cert-auth-dal.ts | 10 +
.../identity-tls-cert-auth-service.ts | 417 ++++++++++++++++++
.../identity-tls-cert-auth-types.ts | 49 ++
16 files changed, 1005 insertions(+), 2 deletions(-)
create mode 100644 backend/src/db/migrations/20250624061429_identity-tls-auth.ts
create mode 100644 backend/src/db/schemas/identity-tls-cert-auths.ts
create mode 100644 backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
create mode 100644 backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts
create mode 100644 backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
create mode 100644 backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 4fe4e17cf..2d641b4a0 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -110,6 +110,7 @@ import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
+import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types";
declare module "@fastify/request-context" {
interface RequestContextData {
@@ -218,6 +219,7 @@ declare module "fastify" {
identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory;
identityGcpAuth: TIdentityGcpAuthServiceFactory;
identityAliCloudAuth: TIdentityAliCloudAuthServiceFactory;
+ identityTlsCertAuth: TIdentityTlsCertAuthServiceFactory;
identityAwsAuth: TIdentityAwsAuthServiceFactory;
identityAzureAuth: TIdentityAzureAuthServiceFactory;
identityOciAuth: TIdentityOciAuthServiceFactory;
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 1d4ad1797..7ead9f84b 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -164,6 +164,9 @@ import {
TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate,
+ TIdentityTlsCertAuths,
+ TIdentityTlsCertAuthsInsert,
+ TIdentityTlsCertAuthsUpdate,
TIdentityTokenAuths,
TIdentityTokenAuthsInsert,
TIdentityTokenAuthsUpdate,
@@ -794,6 +797,11 @@ declare module "knex/types/tables" {
TIdentityAlicloudAuthsInsert,
TIdentityAlicloudAuthsUpdate
>;
+ [TableName.IdentityTlsCertAuth]: KnexOriginal.CompositeTableType<
+ TIdentityTlsCertAuths,
+ TIdentityTlsCertAuthsInsert,
+ TIdentityTlsCertAuthsUpdate
+ >;
[TableName.IdentityAwsAuth]: KnexOriginal.CompositeTableType<
TIdentityAwsAuths,
TIdentityAwsAuthsInsert,
diff --git a/backend/src/db/migrations/20250624061429_identity-tls-auth.ts b/backend/src/db/migrations/20250624061429_identity-tls-auth.ts
new file mode 100644
index 000000000..3e6dc1af8
--- /dev/null
+++ b/backend/src/db/migrations/20250624061429_identity-tls-auth.ts
@@ -0,0 +1,28 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.IdentityTlsCertAuth))) {
+ await knex.schema.createTable(TableName.IdentityTlsCertAuth, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable();
+ t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable();
+ t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable();
+ t.jsonb("accessTokenTrustedIps").notNullable();
+ t.timestamps(true, true, true);
+ t.uuid("identityId").notNullable().unique();
+ t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
+ t.string("allowedCommonNames").nullable();
+ t.binary("encryptedCaCertificate").notNullable();
+ });
+ }
+
+ await createOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth);
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.schema.dropTableIfExists(TableName.IdentityTlsCertAuth);
+ await dropOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth);
+}
diff --git a/backend/src/db/schemas/identity-tls-cert-auths.ts b/backend/src/db/schemas/identity-tls-cert-auths.ts
new file mode 100644
index 000000000..c907ead73
--- /dev/null
+++ b/backend/src/db/schemas/identity-tls-cert-auths.ts
@@ -0,0 +1,27 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { zodBuffer } from "@app/lib/zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const IdentityTlsCertAuthsSchema = z.object({
+ id: z.string().uuid(),
+ accessTokenTTL: z.coerce.number().default(7200),
+ accessTokenMaxTTL: z.coerce.number().default(7200),
+ accessTokenNumUsesLimit: z.coerce.number().default(0),
+ accessTokenTrustedIps: z.unknown(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ identityId: z.string().uuid(),
+ allowedCommonNames: z.string().nullable().optional(),
+ encryptedCaCertificate: zodBuffer
+});
+
+export type TIdentityTlsCertAuths = z.infer;
+export type TIdentityTlsCertAuthsInsert = Omit, TImmutableDBKeys>;
+export type TIdentityTlsCertAuthsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index 292551c80..1642c3555 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -52,6 +52,7 @@ export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege";
export * from "./identity-project-membership-role";
export * from "./identity-project-memberships";
+export * from "./identity-tls-cert-auths";
export * from "./identity-token-auths";
export * from "./identity-ua-client-secrets";
export * from "./identity-universal-auths";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index ceba6e370..d47962110 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -86,6 +86,7 @@ export enum TableName {
IdentityOidcAuth = "identity_oidc_auths",
IdentityJwtAuth = "identity_jwt_auths",
IdentityLdapAuth = "identity_ldap_auths",
+ IdentityTlsCertAuth = "identity_tls_cert_auths",
IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
@@ -251,6 +252,7 @@ export enum IdentityAuthMethod {
ALICLOUD_AUTH = "alicloud-auth",
AWS_AUTH = "aws-auth",
AZURE_AUTH = "azure-auth",
+ TLS_CERT_AUTH = "tls-cert-auth",
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
JWT_AUTH = "jwt-auth",
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index e72b9fa46..a2acb62c4 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -202,6 +202,12 @@ export enum EventType {
REVOKE_IDENTITY_ALICLOUD_AUTH = "revoke-identity-alicloud-auth",
GET_IDENTITY_ALICLOUD_AUTH = "get-identity-alicloud-auth",
+ LOGIN_IDENTITY_TLS_CERT_AUTH = "login-identity-tls-cert-auth",
+ ADD_IDENTITY_TLS_CERT_AUTH = "add-identity-tls-cert-auth",
+ UPDATE_IDENTITY_TLS_CERT_AUTH = "update-identity-tls-cert-auth",
+ REVOKE_IDENTITY_TLS_CERT_AUTH = "revoke-identity-tls-cert-auth",
+ GET_IDENTITY_TLS_CERT_AUTH = "get-identity-tls-cert-auth",
+
LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth",
ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth",
UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth",
@@ -1141,6 +1147,53 @@ interface GetIdentityAliCloudAuthEvent {
};
}
+interface LoginIdentityTlsCertAuthEvent {
+ type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH;
+ metadata: {
+ identityId: string;
+ identityTlsCertAuthId: string;
+ identityAccessTokenId: string;
+ };
+}
+
+interface AddIdentityTlsCertAuthEvent {
+ type: EventType.ADD_IDENTITY_TLS_CERT_AUTH;
+ metadata: {
+ identityId: string;
+ allowedCommonNames: string | null | undefined;
+ accessTokenTTL: number;
+ accessTokenMaxTTL: number;
+ accessTokenNumUsesLimit: number;
+ accessTokenTrustedIps: Array;
+ };
+}
+
+interface DeleteIdentityTlsCertAuthEvent {
+ type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH;
+ metadata: {
+ identityId: string;
+ };
+}
+
+interface UpdateIdentityTlsCertAuthEvent {
+ type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH;
+ metadata: {
+ identityId: string;
+ allowedCommonNames: string | null | undefined;
+ accessTokenTTL?: number;
+ accessTokenMaxTTL?: number;
+ accessTokenNumUsesLimit?: number;
+ accessTokenTrustedIps?: Array;
+ };
+}
+
+interface GetIdentityTlsCertAuthEvent {
+ type: EventType.GET_IDENTITY_TLS_CERT_AUTH;
+ metadata: {
+ identityId: string;
+ };
+}
+
interface LoginIdentityOciAuthEvent {
type: EventType.LOGIN_IDENTITY_OCI_AUTH;
metadata: {
@@ -3358,6 +3411,11 @@ export type Event =
| UpdateIdentityAliCloudAuthEvent
| GetIdentityAliCloudAuthEvent
| DeleteIdentityAliCloudAuthEvent
+ | LoginIdentityTlsCertAuthEvent
+ | AddIdentityTlsCertAuthEvent
+ | UpdateIdentityTlsCertAuthEvent
+ | GetIdentityTlsCertAuthEvent
+ | DeleteIdentityTlsCertAuthEvent
| LoginIdentityOciAuthEvent
| AddIdentityOciAuthEvent
| UpdateIdentityOciAuthEvent
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 67b70079b..18cb4889c 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -22,6 +22,7 @@ export enum ApiDocsTags {
UniversalAuth = "Universal Auth",
GcpAuth = "GCP Auth",
AliCloudAuth = "Alibaba Cloud Auth",
+ TlsCertAuth = "TLS Certificate Auth",
AwsAuth = "AWS Auth",
OciAuth = "OCI Auth",
AzureAuth = "Azure Auth",
@@ -283,6 +284,38 @@ export const ALICLOUD_AUTH = {
}
} as const;
+export const TLS_CERT_AUTH = {
+ LOGIN: {
+ identityId: "The ID of the identity to login."
+ },
+ ATTACH: {
+ identityId: "The ID of the identity to attach the configuration onto.",
+ allowedCommonNames:
+ "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.",
+ caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
+ accessTokenTTL: "The lifetime for an access token in seconds.",
+ accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.",
+ accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.",
+ accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from."
+ },
+ UPDATE: {
+ identityId: "The ID of the identity to update the auth method for.",
+ allowedCommonNames:
+ "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.",
+ caCertificate: "The PEM-encoded CA certificate to validate client certificates.",
+ accessTokenTTL: "The new lifetime for an access token in seconds.",
+ accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
+ accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
+ accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from."
+ },
+ RETRIEVE: {
+ identityId: "The ID of the identity to retrieve the auth method for."
+ },
+ REVOKE: {
+ identityId: "The ID of the identity to revoke the auth method for."
+ }
+} as const;
+
export const AWS_AUTH = {
LOGIN: {
identityId: "The ID of the identity to login.",
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index bde1b805e..3e5735fb9 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -301,6 +301,8 @@ import { registerSecretScannerGhApp } from "../plugins/secret-scanner";
import { registerV1Routes } from "./v1";
import { registerV2Routes } from "./v2";
import { registerV3Routes } from "./v3";
+import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal";
+import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service";
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
@@ -386,6 +388,7 @@ export const registerRoutes = async (
const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
const identityAliCloudAuthDAL = identityAliCloudAuthDALFactory(db);
+ const identityTlsCertAuthDAL = identityTlsCertAuthDALFactory(db);
const identityAwsAuthDAL = identityAwsAuthDALFactory(db);
const identityGcpAuthDAL = identityGcpAuthDALFactory(db);
const identityOciAuthDAL = identityOciAuthDALFactory(db);
@@ -1493,6 +1496,15 @@ export const registerRoutes = async (
permissionService
});
+ const identityTlsCertAuthService = identityTlsCertAuthServiceFactory({
+ identityAccessTokenDAL,
+ identityTlsCertAuthDAL,
+ identityOrgMembershipDAL,
+ licenseService,
+ permissionService,
+ kmsService
+ });
+
const identityAwsAuthService = identityAwsAuthServiceFactory({
identityAccessTokenDAL,
identityAwsAuthDAL,
@@ -1947,6 +1959,7 @@ export const registerRoutes = async (
identityAwsAuth: identityAwsAuthService,
identityAzureAuth: identityAzureAuthService,
identityOciAuth: identityOciAuthService,
+ identityTlsCertAuth: identityTlsCertAuthService,
identityOidcAuth: identityOidcAuthService,
identityJwtAuth: identityJwtAuthService,
identityLdapAuth: identityLdapAuthService,
diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
new file mode 100644
index 000000000..38190d7e7
--- /dev/null
+++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
@@ -0,0 +1,345 @@
+import { z } from "zod";
+
+// import { TLSSocket } from "tls";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { ApiDocsTags, TLS_CERT_AUTH } from "@app/lib/api-docs";
+import { IdentityTlsCertAuthsSchema } from "@app/db/schemas";
+import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns";
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
+
+const validateCommonNames = z
+ .string()
+ .min(1)
+ .trim()
+ .transform((el) =>
+ el
+ .split(",")
+ .map((i) => i.trim())
+ .join(",")
+ );
+
+export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvider) => {
+ // server.route({
+ // method: "GET",
+ // url: "/",
+ // config: {
+ // rateLimit: readLimit
+ // },
+ // schema: {
+ // params: z.object({}),
+ // response: {
+ // 200: z.object({})
+ // }
+ // },
+ // onRequest: verifyAuth([AuthMode.JWT]),
+ // handler: async (req) => {
+ // const { socket } = req;
+ // if (socket instanceof TLSSocket && socket.encrypted) {
+ // // Inside this block, TypeScript now knows `socket` is a TlsSocket
+ // const certificate = socket.getPeerCertificate();
+ //
+ // if (Object.keys(certificate).length === 0) {
+ // return reply.send({ message: "Client did not provide a certificate." });
+ // }
+ //
+ // return reply.send({
+ // message: "Certificate received!",
+ // subject: certificate.subject,
+ // issuer: certificate.issuer,
+ // fingerprint: certificate.fingerprint
+ // });
+ // } else {
+ // // This will handle plain HTTP requests gracefully
+ // return reply
+ // .status(400)
+ // .send({ error: "This endpoint requires an HTTPS connection with a client certificate." });
+ // }
+ // }
+ // });
+
+ server.route({
+ method: "POST",
+ url: "/identities/:identityId",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.TlsCertAuth],
+ description: "Attach TLS Certificate Auth configuration onto identity",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ identityId: z.string().trim().describe(TLS_CERT_AUTH.ATTACH.identityId)
+ }),
+ body: z
+ .object({
+ allowedCommonNames: validateCommonNames.describe(TLS_CERT_AUTH.ATTACH.allowedCommonNames),
+ caCertificate: z.string().min(1).describe(TLS_CERT_AUTH.ATTACH.caCertificate),
+ accessTokenTrustedIps: z
+ .object({
+ ipAddress: z.string().trim()
+ })
+ .array()
+ .min(1)
+ .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
+ .describe(TLS_CERT_AUTH.ATTACH.accessTokenTrustedIps),
+ accessTokenTTL: z
+ .number()
+ .int()
+ .min(0)
+ .max(315360000)
+ .default(2592000)
+ .describe(TLS_CERT_AUTH.ATTACH.accessTokenTTL),
+ accessTokenMaxTTL: z
+ .number()
+ .int()
+ .min(1)
+ .max(315360000)
+ .default(2592000)
+ .describe(TLS_CERT_AUTH.ATTACH.accessTokenMaxTTL),
+ accessTokenNumUsesLimit: z
+ .number()
+ .int()
+ .min(0)
+ .default(0)
+ .describe(TLS_CERT_AUTH.ATTACH.accessTokenNumUsesLimit)
+ })
+ .refine(
+ (val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
+ "Access Token TTL cannot be greater than Access Token Max TTL."
+ ),
+ response: {
+ 200: z.object({
+ identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ })
+ }
+ },
+ handler: async (req) => {
+ const identityTlsCertAuth = await server.services.identityTlsCertAuth.attachTlsCertAuth({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.body,
+ identityId: req.params.identityId,
+ isActorSuperAdmin: isSuperAdmin(req.auth)
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.ADD_IDENTITY_TLS_CERT_AUTH,
+ metadata: {
+ identityId: identityTlsCertAuth.identityId,
+ allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
+ accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
+ accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
+ accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
+ accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit
+ }
+ }
+ });
+
+ return { identityTlsCertAuth };
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/identities/:identityId",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.TlsCertAuth],
+ description: "Update Tls Certificate Auth configuration on identity",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ identityId: z.string().describe(TLS_CERT_AUTH.UPDATE.identityId)
+ }),
+ body: z
+ .object({
+ allowedCommonNames: validateCommonNames.describe(TLS_CERT_AUTH.UPDATE.allowedCommonNames),
+ accessTokenTrustedIps: z
+ .object({
+ ipAddress: z.string().trim()
+ })
+ .array()
+ .min(1)
+ .optional()
+ .describe(TLS_CERT_AUTH.UPDATE.accessTokenTrustedIps),
+ accessTokenTTL: z
+ .number()
+ .int()
+ .min(0)
+ .max(315360000)
+ .optional()
+ .describe(TLS_CERT_AUTH.UPDATE.accessTokenTTL),
+ accessTokenNumUsesLimit: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe(TLS_CERT_AUTH.UPDATE.accessTokenNumUsesLimit),
+ accessTokenMaxTTL: z
+ .number()
+ .int()
+ .max(315360000)
+ .min(0)
+ .optional()
+ .describe(TLS_CERT_AUTH.UPDATE.accessTokenMaxTTL)
+ })
+ .refine(
+ (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
+ "Access Token TTL cannot be greater than Access Token Max TTL."
+ ),
+ response: {
+ 200: z.object({
+ identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ })
+ }
+ },
+ handler: async (req) => {
+ const identityTlsCertAuth = await server.services.identityTlsCertAuth.updateTlsCertAuth({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.body,
+ identityId: req.params.identityId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH,
+ metadata: {
+ identityId: identityTlsCertAuth.identityId,
+ allowedCommonNames: identityTlsCertAuth.allowedCommonNames,
+ accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
+ accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
+ accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
+ accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit
+ }
+ }
+ });
+
+ return { identityTlsCertAuth };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/identities/:identityId",
+ config: {
+ rateLimit: readLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.TlsCertAuth],
+ description: "Retrieve Tls Certificate Auth configuration on identity",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ identityId: z.string().describe(TLS_CERT_AUTH.RETRIEVE.identityId)
+ }),
+ response: {
+ 200: z.object({
+ identityTlsCloudAuth: IdentityTlsCertAuthsSchema.extend({
+ caCertificate: z.string()
+ })
+ })
+ }
+ },
+ handler: async (req) => {
+ const identityTlsCertAuth = await server.services.identityTlsCertAuth.getTlsCertAuth({
+ identityId: req.params.identityId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ actorAuthMethod: req.permission.authMethod
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.GET_IDENTITY_TLS_CERT_AUTH,
+ metadata: {
+ identityId: identityTlsCertAuth.identityId
+ }
+ }
+ });
+ return { identityTlsCertAuth };
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/identities/:identityId",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.TlsCertAuth],
+ description: "Delete Tls Certificate Auth configuration on identity",
+ security: [
+ {
+ bearerAuth: []
+ }
+ ],
+ params: z.object({
+ identityId: z.string().describe(TLS_CERT_AUTH.REVOKE.identityId)
+ }),
+ response: {
+ 200: z.object({
+ identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ })
+ }
+ },
+ handler: async (req) => {
+ const identityTlsCertAuth = await server.services.identityTlsCertAuth.revokeTlsCertAuth({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ identityId: req.params.identityId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: req.permission.orgId,
+ event: {
+ type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH,
+ metadata: {
+ identityId: identityTlsCertAuth.identityId
+ }
+ }
+ });
+
+ return { identityTlsCertAuth };
+ }
+ });
+};
diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts
index 2363147b6..bebdfbe65 100644
--- a/backend/src/server/routes/v1/index.ts
+++ b/backend/src/server/routes/v1/index.ts
@@ -53,6 +53,7 @@ import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-router";
import { registerWebhookRouter } from "./webhook-router";
import { registerWorkflowIntegrationRouter } from "./workflow-integration-router";
+import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router";
export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerSsoRouter, { prefix: "/sso" });
@@ -66,6 +67,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await authRouter.register(registerIdentityAccessTokenRouter);
await authRouter.register(registerIdentityAliCloudAuthRouter);
await authRouter.register(registerIdentityAwsAuthRouter);
+ await authRouter.register(registerIdentityTlsCertAuthRouter, { prefix: "/tls-cert-auth" });
await authRouter.register(registerIdentityAzureAuthRouter);
await authRouter.register(registerIdentityOciAuthRouter);
await authRouter.register(registerIdentityOidcAuthRouter);
diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts
index 879ca9fd3..a1b70b21c 100644
--- a/backend/src/services/identity-access-token/identity-access-token-dal.ts
+++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts
@@ -45,6 +45,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
.leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`)
.leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`)
.leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`)
+ .leftJoin(
+ TableName.IdentityTlsCertAuth,
+ `${TableName.Identity}.id`,
+ `${TableName.IdentityTlsCertAuth}.identityId`
+ )
.select(selectAllTableCols(TableName.IdentityAccessToken))
.select(
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
@@ -61,6 +66,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"),
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"),
+ db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTlsCertAuth).as("accessTokenTrustedIpsTlsCert"),
db.ref("name").withSchema(TableName.Identity)
)
.first();
@@ -79,7 +85,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc,
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken,
trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt,
- trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap
+ trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap,
+ trustedIpsAccessTlsCertAuth: doc.accessTokenTrustedIpsTlsCert
};
} catch (error) {
throw new DatabaseError({ error, name: "IdAccessTokenFindOne" });
diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts
index 7c8944f50..587b7c436 100644
--- a/backend/src/services/identity-access-token/identity-access-token-service.ts
+++ b/backend/src/services/identity-access-token/identity-access-token-service.ts
@@ -201,7 +201,8 @@ export const identityAccessTokenServiceFactory = ({
[IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth,
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth,
[IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth,
- [IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth
+ [IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth,
+ [IdentityAuthMethod.TLS_CERT_AUTH]: identityAccessToken.trustedIpsAccessTlsCertAuth
};
const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod];
diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts
new file mode 100644
index 000000000..951077f33
--- /dev/null
+++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts
@@ -0,0 +1,10 @@
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { ormify, TOrmify } from "@app/lib/knex";
+
+export type TIdentityTlsCertAuthDALFactory = TOrmify;
+
+export const identityTlsCertAuthDALFactory = (db: TDbClient) => {
+ const orm = ormify(db, TableName.IdentityTlsCertAuth);
+ return orm;
+};
diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
new file mode 100644
index 000000000..8a416156d
--- /dev/null
+++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
@@ -0,0 +1,417 @@
+import crypto from "node:crypto";
+
+import { ForbiddenError } from "@casl/ability";
+import jwt from "jsonwebtoken";
+
+import { IdentityAuthMethod } from "@app/db/schemas";
+import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
+import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
+import {
+ constructPermissionErrorMessage,
+ validatePrivilegeChangeOperation
+} from "@app/ee/services/permission/permission-fns";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import { getConfig } from "@app/lib/config/env";
+import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
+import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
+
+import { ActorType, AuthTokenType } from "../auth/auth-type";
+import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
+import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
+import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
+import { TKmsServiceFactory } from "../kms/kms-service";
+import { KmsDataKey } from "../kms/kms-types";
+import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns";
+import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal";
+import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types";
+
+type TIdentityTlsCertAuthServiceFactoryDep = {
+ identityAccessTokenDAL: Pick;
+ identityTlsCertAuthDAL: Pick<
+ TIdentityTlsCertAuthDALFactory,
+ "findOne" | "transaction" | "create" | "updateById" | "delete"
+ >;
+ identityOrgMembershipDAL: Pick;
+ licenseService: Pick;
+ permissionService: Pick;
+ kmsService: Pick;
+};
+
+const parseSubjectDetails = (data: string) => {
+ const values: Record = {};
+ data.split("\n").forEach((el) => {
+ const [key, value] = el.split("=");
+ values[key.trim()] = value.trim();
+ });
+ return values;
+};
+
+export const identityTlsCertAuthServiceFactory = ({
+ identityAccessTokenDAL,
+ identityTlsCertAuthDAL,
+ identityOrgMembershipDAL,
+ licenseService,
+ permissionService,
+ kmsService
+}: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => {
+ const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => {
+ const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
+ if (!identityTlsCertAuth) {
+ throw new NotFoundError({
+ message: "TLS Certificate auth method not found for identity, did you configure TLS Certificate auth?"
+ });
+ }
+
+ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({
+ identityId: identityTlsCertAuth.identityId
+ });
+
+ if (!identityMembershipOrg) {
+ throw new NotFoundError({
+ message: `Identity organization membership for identity with ID '${identityTlsCertAuth.identityId}' not found`
+ });
+ }
+
+ const { decryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.Organization,
+ orgId: identityMembershipOrg.orgId
+ });
+
+ const caCertificate = decryptor({
+ cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate
+ }).toString();
+
+ const clientCertificateX509 = new crypto.X509Certificate(Buffer.from(clientCertificate));
+ const caCertificateX509 = new crypto.X509Certificate(caCertificate);
+
+ const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
+ if (!isValidCertificate)
+ throw new UnauthorizedError({
+ message: "Access denied: Certificate not issued by the provided CA."
+ });
+
+ if (new Date(clientCertificateX509.validTo) < new Date()) {
+ throw new UnauthorizedError({
+ message: "Access denied: Certificate has expired."
+ });
+ }
+
+ if (new Date(clientCertificateX509.validFrom) > new Date()) {
+ throw new UnauthorizedError({
+ message: "Access denied: Certificate not yet valid."
+ });
+ }
+
+ const subjectDetails = parseSubjectDetails(clientCertificateX509.subject);
+ if (identityTlsCertAuth.allowedCommonNames) {
+ const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
+ if (!isValidCommonName) {
+ throw new UnauthorizedError({
+ message: "Access denied: TLS Certificate Auth common name not allowed."
+ });
+ }
+ }
+
+ // Generate the token
+ const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => {
+ const newToken = await identityAccessTokenDAL.create(
+ {
+ identityId: identityTlsCertAuth.identityId,
+ isAccessTokenRevoked: false,
+ accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
+ accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
+ accessTokenNumUses: 0,
+ accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit,
+ authMethod: IdentityAuthMethod.TLS_CERT_AUTH
+ },
+ tx
+ );
+ return newToken;
+ });
+
+ const appCfg = getConfig();
+ const accessToken = jwt.sign(
+ {
+ identityId: identityTlsCertAuth.identityId,
+ identityAccessTokenId: identityAccessToken.id,
+ authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
+ } as TIdentityAccessTokenJwtPayload,
+ appCfg.AUTH_SECRET,
+ Number(identityAccessToken.accessTokenTTL) === 0
+ ? undefined
+ : {
+ expiresIn: Number(identityAccessToken.accessTokenTTL)
+ }
+ );
+
+ return {
+ identityTlsCertAuth,
+ accessToken,
+ identityAccessToken,
+ identityMembershipOrg
+ };
+ };
+
+ const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({
+ identityId,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps,
+ actorId,
+ actorAuthMethod,
+ actor,
+ actorOrgId,
+ isActorSuperAdmin,
+ caCertificate,
+ allowedCommonNames
+ }) => {
+ await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
+
+ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
+ if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
+
+ if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
+ throw new BadRequestError({
+ message: "Failed to add TLS Certificate Auth to already configured identity"
+ });
+ }
+
+ if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
+ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
+ }
+
+ const { permission } = await permissionService.getOrgPermission(
+ actor,
+ actorId,
+ identityMembershipOrg.orgId,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity);
+
+ const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
+ const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
+ if (
+ !plan.ipAllowlisting &&
+ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
+ accessTokenTrustedIp.ipAddress !== "::/0"
+ )
+ throw new BadRequestError({
+ message:
+ "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
+ });
+ if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
+ throw new BadRequestError({
+ message: "The IP is not a valid IPv4, IPv6, or CIDR block"
+ });
+ return extractIPDetails(accessTokenTrustedIp.ipAddress);
+ });
+
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.Organization,
+ orgId: identityMembershipOrg.orgId
+ });
+
+ const identityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => {
+ const doc = await identityTlsCertAuthDAL.create(
+ {
+ identityId: identityMembershipOrg.identityId,
+ accessTokenMaxTTL,
+ allowedCommonNames,
+ accessTokenTTL,
+ encryptedCaCertificate: encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
+ },
+ tx
+ );
+ return doc;
+ });
+ return { ...identityTlsCertAuth, orgId: identityMembershipOrg.orgId };
+ };
+
+ const updateTlsCertAuth: TIdentityTlsCertAuthServiceFactory["updateTlsCertAuth"] = async ({
+ identityId,
+ caCertificate,
+ allowedCommonNames,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps,
+ actorId,
+ actorAuthMethod,
+ actor,
+ actorOrgId
+ }) => {
+ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
+ if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
+
+ if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
+ throw new NotFoundError({
+ message: "The identity does not have TLS Certificate Auth attached"
+ });
+ }
+
+ const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
+
+ if (
+ (accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL) > 0 &&
+ (accessTokenTTL || identityTlsCertAuth.accessTokenTTL) >
+ (accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL)
+ ) {
+ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
+ }
+
+ const { permission } = await permissionService.getOrgPermission(
+ actor,
+ actorId,
+ identityMembershipOrg.orgId,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
+
+ const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
+ const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
+ if (
+ !plan.ipAllowlisting &&
+ accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
+ accessTokenTrustedIp.ipAddress !== "::/0"
+ )
+ throw new BadRequestError({
+ message:
+ "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
+ });
+ if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
+ throw new BadRequestError({
+ message: "The IP is not a valid IPv4, IPv6, or CIDR block"
+ });
+ return extractIPDetails(accessTokenTrustedIp.ipAddress);
+ });
+ const { encryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.Organization,
+ orgId: identityMembershipOrg.orgId
+ });
+
+ const updatedTlsCertAuth = await identityTlsCertAuthDAL.updateById(identityTlsCertAuth.id, {
+ allowedCommonNames,
+ encryptedCaCertificate: caCertificate
+ ? encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob
+ : undefined,
+ accessTokenMaxTTL,
+ accessTokenTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps: reformattedAccessTokenTrustedIps
+ ? JSON.stringify(reformattedAccessTokenTrustedIps)
+ : undefined
+ });
+
+ return { ...updatedTlsCertAuth, orgId: identityMembershipOrg.orgId };
+ };
+
+ const getTlsCertAuth: TIdentityTlsCertAuthServiceFactory["getTlsCertAuth"] = async ({
+ identityId,
+ actorId,
+ actor,
+ actorAuthMethod,
+ actorOrgId
+ }) => {
+ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
+ if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
+
+ if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
+ throw new BadRequestError({
+ message: "The identity does not have TLS Certificate Auth attached"
+ });
+ }
+
+ const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId });
+
+ const { permission } = await permissionService.getOrgPermission(
+ actor,
+ actorId,
+ identityMembershipOrg.orgId,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
+ const { decryptor } = await kmsService.createCipherPairWithDataKey({
+ type: KmsDataKey.Organization,
+ orgId: identityMembershipOrg.orgId
+ });
+ let caCertificate = "";
+ if (identityAuth.encryptedCaCertificate) {
+ caCertificate = decryptor({ cipherTextBlob: identityAuth.encryptedCaCertificate }).toString();
+ }
+
+ return { ...identityAuth, caCertificate, orgId: identityMembershipOrg.orgId };
+ };
+
+ const revokeTlsCertAuth: TIdentityTlsCertAuthServiceFactory["revokeTlsCertAuth"] = async ({
+ identityId,
+ actorId,
+ actor,
+ actorAuthMethod,
+ actorOrgId
+ }) => {
+ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
+ if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
+ if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) {
+ throw new BadRequestError({
+ message: "The identity does not have TLS Certificate auth"
+ });
+ }
+ const { permission, membership } = await permissionService.getOrgPermission(
+ actor,
+ actorId,
+ identityMembershipOrg.orgId,
+ actorAuthMethod,
+ actorOrgId
+ );
+ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
+
+ const { permission: rolePermission } = await permissionService.getOrgPermission(
+ ActorType.IDENTITY,
+ identityMembershipOrg.identityId,
+ identityMembershipOrg.orgId,
+ actorAuthMethod,
+ actorOrgId
+ );
+
+ const permissionBoundary = validatePrivilegeChangeOperation(
+ membership.shouldUseNewPrivilegeSystem,
+ OrgPermissionIdentityActions.RevokeAuth,
+ OrgPermissionSubjects.Identity,
+ permission,
+ rolePermission
+ );
+
+ if (!permissionBoundary.isValid)
+ throw new PermissionBoundaryError({
+ message: constructPermissionErrorMessage(
+ "Failed to revoke TLS Certificate auth of identity with more privileged role",
+ membership.shouldUseNewPrivilegeSystem,
+ OrgPermissionIdentityActions.RevokeAuth,
+ OrgPermissionSubjects.Identity
+ ),
+ details: { missingPermissions: permissionBoundary.missingPermissions }
+ });
+
+ const revokedIdentityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => {
+ const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx);
+ await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx);
+
+ return { ...deletedTlsCertAuth?.[0], orgId: identityMembershipOrg.orgId };
+ });
+ return revokedIdentityTlsCertAuth;
+ };
+
+ return {
+ login,
+ attachTlsCertAuth,
+ updateTlsCertAuth,
+ getTlsCertAuth,
+ revokeTlsCertAuth
+ };
+};
diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
new file mode 100644
index 000000000..729f502a2
--- /dev/null
+++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
@@ -0,0 +1,49 @@
+import { TIdentityAccessTokens, TIdentityOrgMemberships, TIdentityTlsCertAuths } from "@app/db/schemas";
+import { TProjectPermission } from "@app/lib/types";
+
+export type TLoginTlsCertAuthDTO = {
+ identityId: string;
+ clientCertificate: string;
+};
+
+export type TAttachTlsCertAuthDTO = {
+ identityId: string;
+ caCertificate: string;
+ allowedCommonNames?: string;
+ accessTokenTTL: number;
+ accessTokenMaxTTL: number;
+ accessTokenNumUsesLimit: number;
+ accessTokenTrustedIps: { ipAddress: string }[];
+ isActorSuperAdmin?: boolean;
+} & Omit;
+
+export type TUpdateTlsCertAuthDTO = {
+ identityId: string;
+ caCertificate?: string;
+ allowedCommonNames?: string;
+ accessTokenTTL?: number;
+ accessTokenMaxTTL?: number;
+ accessTokenNumUsesLimit?: number;
+ accessTokenTrustedIps?: { ipAddress: string }[];
+} & Omit;
+
+export type TGetTlsCertAuthDTO = {
+ identityId: string;
+} & Omit;
+
+export type TRevokeTlsCertAuthDTO = {
+ identityId: string;
+} & Omit;
+
+export type TIdentityTlsCertAuthServiceFactory = {
+ login: (dto: TLoginTlsCertAuthDTO) => Promise<{
+ identityTlsCertAuth: TIdentityTlsCertAuths;
+ accessToken: string;
+ identityAccessToken: TIdentityAccessTokens;
+ identityMembershipOrg: TIdentityOrgMemberships;
+ }>;
+ attachTlsCertAuth: (dto: TAttachTlsCertAuthDTO) => Promise;
+ updateTlsCertAuth: (dto: TUpdateTlsCertAuthDTO) => Promise;
+ revokeTlsCertAuth: (dto: TRevokeTlsCertAuthDTO) => Promise;
+ getTlsCertAuth: (dto: TGetTlsCertAuthDTO) => Promise;
+};
From 4bd62aa46237d7c8db91b7f72f2a5d582671e1d9 Mon Sep 17 00:00:00 2001
From: =
Date: Wed, 25 Jun 2025 14:26:55 +0530
Subject: [PATCH 02/15] feat: updated frontend to have the tls cert auth login
---
backend/src/@types/fastify.d.ts | 2 +-
backend/src/lib/config/env.ts | 3 +
backend/src/server/routes/index.ts | 4 +-
.../v1/identity-tls-cert-auth-router.ts | 151 +++++---
backend/src/server/routes/v1/index.ts | 2 +-
.../identity-tls-cert-auth-service.ts | 8 +-
.../identity-tls-cert-auth-types.ts | 4 +-
backend/src/services/identity/identity-fns.ts | 7 +-
.../src/services/identity/identity-org-dal.ts | 22 +-
.../src/hooks/api/identities/constants.tsx | 3 +-
frontend/src/hooks/api/identities/enums.tsx | 3 +-
.../src/hooks/api/identities/mutations.tsx | 105 +++++
frontend/src/hooks/api/identities/queries.tsx | 24 ++
frontend/src/hooks/api/identities/types.ts | 41 ++
.../IdentityAuthMethodModalContent.tsx | 11 +
.../IdentityTlsCertAuthForm.tsx | 358 ++++++++++++++++++
.../ViewIdentityAuthModal.tsx | 7 +
.../ViewIdentityTlsCertAuthContent.tsx | 88 +++++
18 files changed, 777 insertions(+), 66 deletions(-)
create mode 100644 frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx
create mode 100644 frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 2d641b4a0..2956be192 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -74,6 +74,7 @@ import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-a
import { TIdentityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service";
import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service";
import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
+import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types";
import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service";
import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
@@ -110,7 +111,6 @@ import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
-import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types";
declare module "@fastify/request-context" {
interface RequestContextData {
diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts
index b0b35cc2f..dfe9ac29d 100644
--- a/backend/src/lib/config/env.ts
+++ b/backend/src/lib/config/env.ts
@@ -193,6 +193,9 @@ const envSchema = z
PYLON_API_KEY: zpStr(z.string().optional()),
DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"),
SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"),
+ IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default(
+ "x-identity-tls-cert-auth-client-cert"
+ ),
WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()),
WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()),
ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true"),
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index 3e5735fb9..f687d358d 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -193,6 +193,8 @@ import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
+import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal";
+import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service";
import { identityTokenAuthDALFactory } from "@app/services/identity-token-auth/identity-token-auth-dal";
import { identityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service";
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
@@ -301,8 +303,6 @@ import { registerSecretScannerGhApp } from "../plugins/secret-scanner";
import { registerV1Routes } from "./v1";
import { registerV2Routes } from "./v2";
import { registerV3Routes } from "./v3";
-import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal";
-import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service";
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
index 38190d7e7..2ae64cfe6 100644
--- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
+++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts
@@ -1,14 +1,17 @@
+import crypto from "node:crypto";
+
import { z } from "zod";
-// import { TLSSocket } from "tls";
+import { IdentityTlsCertAuthsSchema } from "@app/db/schemas";
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { ApiDocsTags, TLS_CERT_AUTH } from "@app/lib/api-docs";
+import { getConfig } from "@app/lib/config/env";
+import { BadRequestError } from "@app/lib/errors";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
-import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
-import { ApiDocsTags, TLS_CERT_AUTH } from "@app/lib/api-docs";
-import { IdentityTlsCertAuthsSchema } from "@app/db/schemas";
-import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns";
-import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
+import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns";
const validateCommonNames = z
.string()
@@ -21,44 +24,74 @@ const validateCommonNames = z
.join(",")
);
+const validateCaCertificate = (caCert: string) => {
+ if (!caCert) return true;
+ try {
+ // eslint-disable-next-line no-new
+ new crypto.X509Certificate(caCert);
+ return true;
+ } catch (err) {
+ return false;
+ }
+};
+
export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvider) => {
- // server.route({
- // method: "GET",
- // url: "/",
- // config: {
- // rateLimit: readLimit
- // },
- // schema: {
- // params: z.object({}),
- // response: {
- // 200: z.object({})
- // }
- // },
- // onRequest: verifyAuth([AuthMode.JWT]),
- // handler: async (req) => {
- // const { socket } = req;
- // if (socket instanceof TLSSocket && socket.encrypted) {
- // // Inside this block, TypeScript now knows `socket` is a TlsSocket
- // const certificate = socket.getPeerCertificate();
- //
- // if (Object.keys(certificate).length === 0) {
- // return reply.send({ message: "Client did not provide a certificate." });
- // }
- //
- // return reply.send({
- // message: "Certificate received!",
- // subject: certificate.subject,
- // issuer: certificate.issuer,
- // fingerprint: certificate.fingerprint
- // });
- // } else {
- // // This will handle plain HTTP requests gracefully
- // return reply
- // .status(400)
- // .send({ error: "This endpoint requires an HTTPS connection with a client certificate." });
- // }
- // }
- // });
+ server.route({
+ method: "POST",
+ url: "/login",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.TlsCertAuth],
+ description: "Login with TLS Certificate Auth",
+ body: z.object({
+ identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId)
+ }),
+ response: {
+ 200: z.object({
+ accessToken: z.string(),
+ expiresIn: z.coerce.number(),
+ accessTokenMaxTTL: z.coerce.number(),
+ tokenType: z.literal("Bearer")
+ })
+ }
+ },
+ handler: async (req) => {
+ const appCfg = getConfig();
+ const clientCertificate = req.headers[appCfg.IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY];
+ if (!clientCertificate) {
+ throw new BadRequestError({ message: "Missing TLS certificate in header" });
+ }
+
+ const { identityTlsCertAuth, accessToken, identityAccessToken, identityMembershipOrg } =
+ await server.services.identityTlsCertAuth.login({
+ identityId: req.body.identityId,
+ clientCertificate: clientCertificate as string
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ orgId: identityMembershipOrg?.orgId,
+ event: {
+ type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH,
+ metadata: {
+ identityId: identityTlsCertAuth.identityId,
+ identityAccessTokenId: identityAccessToken.id,
+ identityTlsCertAuthId: identityTlsCertAuth.id
+ }
+ }
+ });
+
+ return {
+ accessToken,
+ tokenType: "Bearer" as const,
+ expiresIn: identityTlsCertAuth.accessTokenTTL,
+ accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL
+ };
+ }
+ });
server.route({
method: "POST",
@@ -81,8 +114,16 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
}),
body: z
.object({
- allowedCommonNames: validateCommonNames.describe(TLS_CERT_AUTH.ATTACH.allowedCommonNames),
- caCertificate: z.string().min(1).describe(TLS_CERT_AUTH.ATTACH.caCertificate),
+ allowedCommonNames: validateCommonNames
+ .optional()
+ .nullable()
+ .describe(TLS_CERT_AUTH.ATTACH.allowedCommonNames),
+ caCertificate: z
+ .string()
+ .min(1)
+ .max(10240)
+ .refine(validateCaCertificate, "Invalid CA Certificate.")
+ .describe(TLS_CERT_AUTH.ATTACH.caCertificate),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
@@ -118,7 +159,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
),
response: {
200: z.object({
- identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
@@ -174,7 +215,17 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
}),
body: z
.object({
- allowedCommonNames: validateCommonNames.describe(TLS_CERT_AUTH.UPDATE.allowedCommonNames),
+ caCertificate: z
+ .string()
+ .min(1)
+ .max(10240)
+ .refine(validateCaCertificate, "Invalid CA Certificate.")
+ .optional()
+ .describe(TLS_CERT_AUTH.ATTACH.caCertificate),
+ allowedCommonNames: validateCommonNames
+ .optional()
+ .nullable()
+ .describe(TLS_CERT_AUTH.UPDATE.allowedCommonNames),
accessTokenTrustedIps: z
.object({
ipAddress: z.string().trim()
@@ -210,7 +261,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
),
response: {
200: z.object({
- identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
@@ -265,7 +316,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
}),
response: {
200: z.object({
- identityTlsCloudAuth: IdentityTlsCertAuthsSchema.extend({
+ identityTlsCertAuth: IdentityTlsCertAuthsSchema.extend({
caCertificate: z.string()
})
})
@@ -315,7 +366,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid
}),
response: {
200: z.object({
- identityTlsCloudAuth: IdentityTlsCertAuthsSchema
+ identityTlsCertAuth: IdentityTlsCertAuthsSchema
})
}
},
diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts
index bebdfbe65..e6aa2e83f 100644
--- a/backend/src/server/routes/v1/index.ts
+++ b/backend/src/server/routes/v1/index.ts
@@ -25,6 +25,7 @@ import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router";
import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router";
import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router";
import { registerIdentityRouter } from "./identity-router";
+import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router";
import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router";
import { registerIdentityUaRouter } from "./identity-universal-auth-router";
import { registerIntegrationAuthRouter } from "./integration-auth-router";
@@ -53,7 +54,6 @@ import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-router";
import { registerWebhookRouter } from "./webhook-router";
import { registerWorkflowIntegrationRouter } from "./workflow-integration-router";
-import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router";
export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerSsoRouter, { prefix: "/sso" });
diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
index 8a416156d..11dd312ad 100644
--- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
+++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts
@@ -11,6 +11,7 @@ import {
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -81,7 +82,12 @@ export const identityTlsCertAuthServiceFactory = ({
cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate
}).toString();
- const clientCertificateX509 = new crypto.X509Certificate(Buffer.from(clientCertificate));
+ const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
+ if (!leafCertificate) {
+ throw new BadRequestError({ message: "Missing client certificate" });
+ }
+
+ const clientCertificateX509 = new crypto.X509Certificate(leafCertificate);
const caCertificateX509 = new crypto.X509Certificate(caCertificate);
const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
index 729f502a2..b7a08276b 100644
--- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
+++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts
@@ -9,7 +9,7 @@ export type TLoginTlsCertAuthDTO = {
export type TAttachTlsCertAuthDTO = {
identityId: string;
caCertificate: string;
- allowedCommonNames?: string;
+ allowedCommonNames?: string | null;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
@@ -20,7 +20,7 @@ export type TAttachTlsCertAuthDTO = {
export type TUpdateTlsCertAuthDTO = {
identityId: string;
caCertificate?: string;
- allowedCommonNames?: string;
+ allowedCommonNames?: string | null;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts
index 3020d9c47..dee87fc49 100644
--- a/backend/src/services/identity/identity-fns.ts
+++ b/backend/src/services/identity/identity-fns.ts
@@ -11,7 +11,8 @@ export const buildAuthMethods = ({
azureId,
tokenId,
jwtId,
- ldapId
+ ldapId,
+ tlsCertId
}: {
uaId?: string;
gcpId?: string;
@@ -24,6 +25,7 @@ export const buildAuthMethods = ({
tokenId?: string;
jwtId?: string;
ldapId?: string;
+ tlsCertId?: string;
}) => {
return [
...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null],
@@ -36,6 +38,7 @@ export const buildAuthMethods = ({
...[azureId ? IdentityAuthMethod.AZURE_AUTH : null],
...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null],
...[jwtId ? IdentityAuthMethod.JWT_AUTH : null],
- ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null]
+ ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null],
+ ...[tlsCertId ? IdentityAuthMethod.TLS_CERT_AUTH : null]
].filter((authMethod) => authMethod) as IdentityAuthMethod[];
};
diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts
index 28064c9bb..5ca6cbbc1 100644
--- a/backend/src/services/identity/identity-org-dal.ts
+++ b/backend/src/services/identity/identity-org-dal.ts
@@ -12,6 +12,7 @@ import {
TIdentityOciAuths,
TIdentityOidcAuths,
TIdentityOrgMemberships,
+ TIdentityTlsCertAuths,
TIdentityTokenAuths,
TIdentityUniversalAuths,
TOrgRoles
@@ -99,7 +100,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityLdapAuth}.identityId`
)
-
+ .leftJoin(
+ TableName.IdentityTlsCertAuth,
+ `${TableName.IdentityOrgMembership}.identityId`,
+ `${TableName.IdentityTlsCertAuth}.identityId`
+ )
.select(
selectAllTableCols(TableName.IdentityOrgMembership),
@@ -114,6 +119,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth),
db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth),
+ db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth),
db.ref("name").withSchema(TableName.Identity),
db.ref("hasDeleteProtection").withSchema(TableName.Identity)
);
@@ -238,7 +244,11 @@ export const identityOrgDALFactory = (db: TDbClient) => {
"paginatedIdentity.identityId",
`${TableName.IdentityLdapAuth}.identityId`
)
-
+ .leftJoin(
+ TableName.IdentityTlsCertAuth,
+ "paginatedIdentity.identityId",
+ `${TableName.IdentityTlsCertAuth}.identityId`
+ )
.select(
db.ref("id").withSchema("paginatedIdentity"),
db.ref("role").withSchema("paginatedIdentity"),
@@ -260,7 +270,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth),
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth),
- db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth)
+ db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth),
+ db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth)
)
// cr stands for custom role
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles))
@@ -306,6 +317,7 @@ export const identityOrgDALFactory = (db: TDbClient) => {
azureId,
tokenId,
ldapId,
+ tlsCertId,
createdAt,
updatedAt
}) => ({
@@ -313,7 +325,6 @@ export const identityOrgDALFactory = (db: TDbClient) => {
roleId,
identityId,
id,
-
orgId,
createdAt,
updatedAt,
@@ -341,7 +352,8 @@ export const identityOrgDALFactory = (db: TDbClient) => {
azureId,
tokenId,
jwtId,
- ldapId
+ ldapId,
+ tlsCertId
})
}
}),
diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx
index cc8d0cef6..b441f5015 100644
--- a/frontend/src/hooks/api/identities/constants.tsx
+++ b/frontend/src/hooks/api/identities/constants.tsx
@@ -11,5 +11,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = {
[IdentityAuthMethod.OCI_AUTH]: "OCI Auth",
[IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth",
[IdentityAuthMethod.LDAP_AUTH]: "LDAP Auth",
- [IdentityAuthMethod.JWT_AUTH]: "JWT Auth"
+ [IdentityAuthMethod.JWT_AUTH]: "JWT Auth",
+ [IdentityAuthMethod.TLS_CERT_AUTH]: "TLS Certificate Auth"
};
diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx
index de329d393..c850005cf 100644
--- a/frontend/src/hooks/api/identities/enums.tsx
+++ b/frontend/src/hooks/api/identities/enums.tsx
@@ -9,7 +9,8 @@ export enum IdentityAuthMethod {
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
LDAP_AUTH = "ldap-auth",
- JWT_AUTH = "jwt-auth"
+ JWT_AUTH = "jwt-auth",
+ TLS_CERT_AUTH = "tls-cert-auth"
}
export enum IdentityJwtConfigurationType {
diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx
index 7abf1a015..613ca85b8 100644
--- a/frontend/src/hooks/api/identities/mutations.tsx
+++ b/frontend/src/hooks/api/identities/mutations.tsx
@@ -14,6 +14,7 @@ import {
AddIdentityLdapAuthDTO,
AddIdentityOciAuthDTO,
AddIdentityOidcAuthDTO,
+ AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO,
ClientSecretData,
@@ -32,6 +33,7 @@ import {
DeleteIdentityLdapAuthDTO,
DeleteIdentityOciAuthDTO,
DeleteIdentityOidcAuthDTO,
+ DeleteIdentityTlsCertAuthDTO,
DeleteIdentityTokenAuthDTO,
DeleteIdentityUniversalAuthClientSecretDTO,
DeleteIdentityUniversalAuthDTO,
@@ -46,6 +48,7 @@ import {
IdentityLdapAuth,
IdentityOciAuth,
IdentityOidcAuth,
+ IdentityTlsCertAuth,
IdentityTokenAuth,
IdentityUniversalAuth,
RevokeTokenDTO,
@@ -60,6 +63,7 @@ import {
UpdateIdentityLdapAuthDTO,
UpdateIdentityOciAuthDTO,
UpdateIdentityOidcAuthDTO,
+ UpdateIdentityTlsCertAuthDTO,
UpdateIdentityTokenAuthDTO,
UpdateIdentityUniversalAuthDTO,
UpdateTokenIdentityTokenAuthDTO
@@ -655,6 +659,107 @@ export const useDeleteIdentityAliCloudAuth = () => {
});
};
+export const useAddIdentityTlsCertAuth = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ identityId,
+ allowedCommonNames,
+ caCertificate,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps
+ }) => {
+ const {
+ data: { identityTlsCertAuth }
+ } = await apiRequest.post<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
+ `/api/v1/auth/tls-cert-auth/identities/${identityId}`,
+ {
+ allowedCommonNames,
+ caCertificate,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps
+ }
+ );
+
+ return identityTlsCertAuth;
+ },
+ onSuccess: (_, { identityId, organizationId }) => {
+ queryClient.invalidateQueries({
+ queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+ });
+ queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
+ queryClient.invalidateQueries({
+ queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
+ });
+ }
+ });
+};
+
+export const useUpdateIdentityTlsCertAuth = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ identityId,
+ allowedCommonNames,
+ caCertificate,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps
+ }) => {
+ const {
+ data: { identityTlsCertAuth }
+ } = await apiRequest.patch<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
+ `/api/v1/auth/tls-cert-auth/identities/${identityId}`,
+ {
+ caCertificate,
+ allowedCommonNames,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps
+ }
+ );
+
+ return identityTlsCertAuth;
+ },
+ onSuccess: (_, { identityId, organizationId }) => {
+ queryClient.invalidateQueries({
+ queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+ });
+ queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
+ queryClient.invalidateQueries({
+ queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
+ });
+ }
+ });
+};
+
+export const useDeleteIdentityTlsCertAuth = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ identityId }) => {
+ const {
+ data: { identityTlsCertAuth }
+ } = await apiRequest.delete(`/api/v1/auth/tls-cert-auth/identities/${identityId}`);
+ return identityTlsCertAuth;
+ },
+ onSuccess: (_, { organizationId, identityId }) => {
+ queryClient.invalidateQueries({
+ queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+ });
+ queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
+ queryClient.invalidateQueries({
+ queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
+ });
+ }
+ });
+};
+
export const useUpdateIdentityOidcAuth = () => {
const queryClient = useQueryClient();
return useMutation({
diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx
index 806c1f0a4..b59526da1 100644
--- a/frontend/src/hooks/api/identities/queries.tsx
+++ b/frontend/src/hooks/api/identities/queries.tsx
@@ -17,6 +17,7 @@ import {
IdentityMembershipOrg,
IdentityOciAuth,
IdentityOidcAuth,
+ IdentityTlsCertAuth,
IdentityTokenAuth,
IdentityUniversalAuth,
TSearchIdentitiesDTO
@@ -34,6 +35,8 @@ export const identitiesKeys = {
getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const,
getIdentityOidcAuth: (identityId: string) => [{ identityId }, "identity-oidc-auth"] as const,
getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const,
+ getIdentityTlsCertAuth: (identityId: string) =>
+ [{ identityId }, "identity-tls-cert-auth"] as const,
getIdentityAliCloudAuth: (identityId: string) =>
[{ identityId }, "identity-alicloud-auth"] as const,
getIdentityOciAuth: (identityId: string) => [{ identityId }, "identity-oci-auth"] as const,
@@ -175,6 +178,27 @@ export const useGetIdentityAwsAuth = (
});
};
+export const useGetIdentityTlsCertAuth = (
+ identityId: string,
+ options?: TReactQueryOptions["options"]
+) => {
+ return useQuery({
+ queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId),
+ queryFn: async () => {
+ const {
+ data: { identityTlsCertAuth }
+ } = await apiRequest.get<{ identityTlsCertAuth: IdentityTlsCertAuth }>(
+ `/api/v1/auth/tls-cert-auth/identities/${identityId}`
+ );
+ return identityTlsCertAuth;
+ },
+ staleTime: 0,
+ gcTime: 0,
+ ...options,
+ enabled: Boolean(identityId) && (options?.enabled ?? true)
+ });
+};
+
export const useGetIdentityOciAuth = (
identityId: string,
options?: TReactQueryOptions["options"]
diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts
index 873338f09..1af1cc24c 100644
--- a/frontend/src/hooks/api/identities/types.ts
+++ b/frontend/src/hooks/api/identities/types.ts
@@ -485,6 +485,47 @@ export type DeleteIdentityKubernetesAuthDTO = {
identityId: string;
};
+export type IdentityTlsCertAuth = {
+ identityId: string;
+ caCertificate: string;
+ allowedCommonNames: string;
+ accessTokenTTL: number;
+ accessTokenMaxTTL: number;
+ accessTokenNumUsesLimit: number;
+ accessTokenTrustedIps: IdentityTrustedIp[];
+};
+
+export type AddIdentityTlsCertAuthDTO = {
+ organizationId: string;
+ identityId: string;
+ caCertificate: string;
+ allowedCommonNames?: string;
+ accessTokenTTL: number;
+ accessTokenMaxTTL: number;
+ accessTokenNumUsesLimit: number;
+ accessTokenTrustedIps: {
+ ipAddress: string;
+ }[];
+};
+
+export type UpdateIdentityTlsCertAuthDTO = {
+ organizationId: string;
+ identityId: string;
+ caCertificate: string;
+ allowedCommonNames?: string | null;
+ accessTokenTTL?: number;
+ accessTokenMaxTTL?: number;
+ accessTokenNumUsesLimit?: number;
+ accessTokenTrustedIps?: {
+ ipAddress: string;
+ }[];
+};
+
+export type DeleteIdentityTlsCertAuthDTO = {
+ organizationId: string;
+ identityId: string;
+};
+
export type CreateIdentityUniversalAuthClientSecretDTO = {
identityId: string;
description?: string;
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx
index 0d44bfe64..57663f441 100644
--- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx
+++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx
@@ -17,6 +17,7 @@ import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm";
import { IdentityLdapAuthForm } from "./IdentityLdapAuthForm";
import { IdentityOciAuthForm } from "./IdentityOciAuthForm";
import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm";
+import { IdentityTlsCertAuthForm } from "./IdentityTlsCertAuthForm";
import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm";
import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm";
@@ -52,6 +53,7 @@ const identityAuthMethods = [
{ label: "OCI Auth", value: IdentityAuthMethod.OCI_AUTH },
{ label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH },
{ label: "LDAP Auth", value: IdentityAuthMethod.LDAP_AUTH },
+ { label: "TLS Certificate Auth", value: IdentityAuthMethod.TLS_CERT_AUTH },
{
label: "JWT Auth",
value: IdentityAuthMethod.JWT_AUTH
@@ -123,6 +125,15 @@ export const IdentityAuthMethodModalContent = ({
/>
)
},
+ [IdentityAuthMethod.TLS_CERT_AUTH]: {
+ render: () => (
+
+ )
+ },
[IdentityAuthMethod.OIDC_AUTH]: {
render: () => (
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx
new file mode 100644
index 000000000..e018bc770
--- /dev/null
+++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx
@@ -0,0 +1,358 @@
+import { useEffect, useState } from "react";
+import { Controller, useFieldArray, useForm } from "react-hook-form";
+import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ IconButton,
+ Input,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ TextArea
+} from "@app/components/v2";
+import { useOrganization, useSubscription } from "@app/context";
+import {
+ useAddIdentityTlsCertAuth,
+ useGetIdentityTlsCertAuth,
+ useUpdateIdentityTlsCertAuth
+} from "@app/hooks/api";
+import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { IdentityFormTab } from "./types";
+
+const schema = z.object({
+ allowedCommonNames: z.string().optional(),
+ caCertificate: z.string().min(1),
+ accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, {
+ message: "Access Token TTL cannot be greater than 315360000"
+ }),
+ accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, {
+ message: "Access Token Max TTL cannot be greater than 315360000"
+ }),
+ accessTokenNumUsesLimit: z.string(),
+ accessTokenTrustedIps: z
+ .array(
+ z.object({
+ ipAddress: z.string().max(50)
+ })
+ )
+ .min(1)
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["identityAuthMethod"]>,
+ state?: boolean
+ ) => void;
+ identityId?: string;
+ isUpdate?: boolean;
+};
+
+export const IdentityTlsCertAuthForm = ({
+ handlePopUpOpen,
+ handlePopUpToggle,
+ identityId,
+ isUpdate
+}: Props) => {
+ const { currentOrg } = useOrganization();
+ const orgId = currentOrg?.id || "";
+ const { subscription } = useSubscription();
+
+ const { mutateAsync: addMutateAsync } = useAddIdentityTlsCertAuth();
+ const { mutateAsync: updateMutateAsync } = useUpdateIdentityTlsCertAuth();
+ const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration);
+
+ const { data } = useGetIdentityTlsCertAuth(identityId ?? "", {
+ enabled: isUpdate
+ });
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ caCertificate: "",
+ accessTokenTTL: "2592000",
+ accessTokenMaxTTL: "2592000",
+ accessTokenNumUsesLimit: "0",
+ accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
+ }
+ });
+
+ const {
+ fields: accessTokenTrustedIpsFields,
+ append: appendAccessTokenTrustedIp,
+ remove: removeAccessTokenTrustedIp
+ } = useFieldArray({ control, name: "accessTokenTrustedIps" });
+
+ useEffect(() => {
+ if (data) {
+ reset({
+ caCertificate: data.caCertificate,
+ allowedCommonNames: data.allowedCommonNames || undefined,
+ accessTokenTTL: String(data.accessTokenTTL),
+ accessTokenMaxTTL: String(data.accessTokenMaxTTL),
+ accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit),
+ accessTokenTrustedIps: data.accessTokenTrustedIps.map(
+ ({ ipAddress, prefix }: IdentityTrustedIp) => {
+ return {
+ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
+ };
+ }
+ )
+ });
+ } else {
+ reset({
+ caCertificate: "",
+ accessTokenTTL: "2592000",
+ accessTokenMaxTTL: "2592000",
+ accessTokenNumUsesLimit: "0",
+ accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
+ });
+ }
+ }, [data]);
+
+ const onFormSubmit = async ({
+ caCertificate,
+ allowedCommonNames,
+ accessTokenTTL,
+ accessTokenMaxTTL,
+ accessTokenNumUsesLimit,
+ accessTokenTrustedIps
+ }: FormData) => {
+ try {
+ if (!identityId) return;
+
+ if (data) {
+ await updateMutateAsync({
+ organizationId: orgId,
+ caCertificate,
+ allowedCommonNames: allowedCommonNames || null,
+ identityId,
+ accessTokenTTL: Number(accessTokenTTL),
+ accessTokenMaxTTL: Number(accessTokenMaxTTL),
+ accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
+ accessTokenTrustedIps
+ });
+ } else {
+ await addMutateAsync({
+ organizationId: orgId,
+ identityId,
+ caCertificate,
+ allowedCommonNames: allowedCommonNames || undefined,
+ accessTokenTTL: Number(accessTokenTTL),
+ accessTokenMaxTTL: Number(accessTokenMaxTTL),
+ accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
+ accessTokenTrustedIps
+ });
+ }
+
+ handlePopUpToggle("identityAuthMethod", false);
+
+ createNotification({
+ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`,
+ type: "success"
+ });
+
+ reset();
+ } catch {
+ createNotification({
+ text: `Failed to ${isUpdate ? "update" : "configure"} identity`,
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx
index 8bfbd09b3..ca0a5e6f3 100644
--- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx
+++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx
@@ -15,6 +15,7 @@ import {
useDeleteIdentityLdapAuth,
useDeleteIdentityOciAuth,
useDeleteIdentityOidcAuth,
+ useDeleteIdentityTlsCertAuth,
useDeleteIdentityTokenAuth,
useDeleteIdentityUniversalAuth
} from "@app/hooks/api";
@@ -29,6 +30,7 @@ import { ViewIdentityKubernetesAuthContent } from "./ViewIdentityKubernetesAuthC
import { ViewIdentityLdapAuthContent } from "./ViewIdentityLdapAuthContent";
import { ViewIdentityOciAuthContent } from "./ViewIdentityOciAuthContent";
import { ViewIdentityOidcAuthContent } from "./ViewIdentityOidcAuthContent";
+import { ViewIdentityTlsCertAuthContent } from "./ViewIdentityTlsCertAuthContent";
import { ViewIdentityTokenAuthContent } from "./ViewIdentityTokenAuthContent";
import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthContent";
@@ -63,6 +65,7 @@ export const Content = ({
const { mutateAsync: revokeTokenAuth } = useDeleteIdentityTokenAuth();
const { mutateAsync: revokeKubernetesAuth } = useDeleteIdentityKubernetesAuth();
const { mutateAsync: revokeGcpAuth } = useDeleteIdentityGcpAuth();
+ const { mutateAsync: revokeTlsCertAuth } = useDeleteIdentityTlsCertAuth();
const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth();
const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth();
const { mutateAsync: revokeAliCloudAuth } = useDeleteIdentityAliCloudAuth();
@@ -93,6 +96,10 @@ export const Content = ({
revokeMethod = revokeGcpAuth;
Component = ViewIdentityGcpAuthContent;
break;
+ case IdentityAuthMethod.TLS_CERT_AUTH:
+ revokeMethod = revokeTlsCertAuth;
+ Component = ViewIdentityTlsCertAuthContent;
+ break;
case IdentityAuthMethod.AWS_AUTH:
revokeMethod = revokeAwsAuth;
Component = ViewIdentityAwsAuthContent;
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx
new file mode 100644
index 000000000..7f4ef7fa6
--- /dev/null
+++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx
@@ -0,0 +1,88 @@
+import { faBan, faEye } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2";
+import { useGetIdentityTlsCertAuth } from "@app/hooks/api";
+import { IdentityTlsCertAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm";
+
+import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
+import { ViewAuthMethodProps } from "./types";
+import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper";
+
+export const ViewIdentityTlsCertAuthContent = ({
+ identityId,
+ handlePopUpToggle,
+ handlePopUpOpen,
+ onDelete,
+ popUp
+}: ViewAuthMethodProps) => {
+ const { data, isPending } = useGetIdentityTlsCertAuth(identityId);
+
+ if (isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ if (popUp.identityAuthMethod.isOpen) {
+ return (
+
+ );
+ }
+
+ return (
+ handlePopUpOpen("identityAuthMethod")}
+ onDelete={onDelete}
+ >
+
+ {data.accessTokenTTL}
+
+
+ {data.accessTokenMaxTTL}
+
+
+ {data.accessTokenNumUsesLimit}
+
+
+ {data.accessTokenTrustedIps.map((ip) => ip.ipAddress).join(", ")}
+
+
+ {data.caCertificate}
}
+ >
+
+
+
+ Reveal
+
+
+
+
+
+ {data.allowedCommonNames
+ ?.split(",")
+ .map((cn) => cn.trim())
+ .join(", ")}
+
+
+ );
+};
From 8ada11edf3a6d1cf29228ca0de750093c4ce54f8 Mon Sep 17 00:00:00 2001
From: =
Date: Wed, 25 Jun 2025 14:27:04 +0530
Subject: [PATCH 03/15] feat: docs for tls cert auth
---
.../endpoints/tls-cert-auth/attach.mdx | 4 +
.../endpoints/tls-cert-auth/login.mdx | 4 +
.../endpoints/tls-cert-auth/retrieve.mdx | 4 +
.../endpoints/tls-cert-auth/revoke.mdx | 4 +
.../endpoints/tls-cert-auth/update.mdx | 4 +
docs/docs.json | 13 +-
.../platform/identities/tls-cert-auth.mdx | 149 ++++++++++++++++++
.../identities-tls-cert-auth-create-auth.png | Bin 0 -> 478524 bytes
docs/self-hosting/configuration/envars.mdx | 29 +++-
9 files changed, 207 insertions(+), 4 deletions(-)
create mode 100644 docs/api-reference/endpoints/tls-cert-auth/attach.mdx
create mode 100644 docs/api-reference/endpoints/tls-cert-auth/login.mdx
create mode 100644 docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx
create mode 100644 docs/api-reference/endpoints/tls-cert-auth/revoke.mdx
create mode 100644 docs/api-reference/endpoints/tls-cert-auth/update.mdx
create mode 100644 docs/documentation/platform/identities/tls-cert-auth.mdx
create mode 100644 docs/images/platform/identities/identities-tls-cert-auth-create-auth.png
diff --git a/docs/api-reference/endpoints/tls-cert-auth/attach.mdx b/docs/api-reference/endpoints/tls-cert-auth/attach.mdx
new file mode 100644
index 000000000..35c3b87e9
--- /dev/null
+++ b/docs/api-reference/endpoints/tls-cert-auth/attach.mdx
@@ -0,0 +1,4 @@
+---
+title: "Attach"
+openapi: "POST /api/v1/auth/tls-cert-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/tls-cert-auth/login.mdx b/docs/api-reference/endpoints/tls-cert-auth/login.mdx
new file mode 100644
index 000000000..0069ef1b7
--- /dev/null
+++ b/docs/api-reference/endpoints/tls-cert-auth/login.mdx
@@ -0,0 +1,4 @@
+---
+title: "Login"
+openapi: "POST /api/v1/auth/tls-cert-auth/login"
+---
diff --git a/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx b/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx
new file mode 100644
index 000000000..d59b31d11
--- /dev/null
+++ b/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx
@@ -0,0 +1,4 @@
+---
+title: "Retrieve"
+openapi: "GET /api/v1/auth/tls-cert-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx b/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx
new file mode 100644
index 000000000..0d3ccda65
--- /dev/null
+++ b/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx
@@ -0,0 +1,4 @@
+---
+title: "Revoke"
+openapi: "DELETE /api/v1/auth/tls-cert-auth/identities/{identityId}"
+---
diff --git a/docs/api-reference/endpoints/tls-cert-auth/update.mdx b/docs/api-reference/endpoints/tls-cert-auth/update.mdx
new file mode 100644
index 000000000..3bb8892ea
--- /dev/null
+++ b/docs/api-reference/endpoints/tls-cert-auth/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/auth/tls-cert-auth/identities/{identityId}"
+---
diff --git a/docs/docs.json b/docs/docs.json
index 2d0417778..967329b17 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -280,7 +280,6 @@
{
"group": "Machine Identities",
"pages": [
- "documentation/platform/identities/alicloud-auth",
"documentation/platform/identities/aws-auth",
"documentation/platform/identities/azure-auth",
"documentation/platform/identities/gcp-auth",
@@ -289,6 +288,8 @@
"documentation/platform/identities/oci-auth",
"documentation/platform/identities/token-auth",
"documentation/platform/identities/universal-auth",
+ "documentation/platform/identities/alicloud-auth",
+ "documentation/platform/identities/tls-cert-auth",
{
"group": "OIDC Auth",
"pages": [
@@ -748,6 +749,16 @@
"api-reference/endpoints/alicloud-auth/revoke"
]
},
+ {
+ "group": "TLS Certificate Auth",
+ "pages": [
+ "api-reference/endpoints/tls-cert-auth/login",
+ "api-reference/endpoints/tls-cert-auth/attach",
+ "api-reference/endpoints/tls-cert-auth/retrieve",
+ "api-reference/endpoints/tls-cert-auth/update",
+ "api-reference/endpoints/tls-cert-auth/revoke"
+ ]
+ },
{
"group": "AWS Auth",
"pages": [
diff --git a/docs/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx
new file mode 100644
index 000000000..be309bf41
--- /dev/null
+++ b/docs/documentation/platform/identities/tls-cert-auth.mdx
@@ -0,0 +1,149 @@
+---
+title: TLS Certificate Auth
+description: "Learn how to authenticate with Infisical using TLS Certificate."
+---
+
+**TLS Certificate Auth** is an authentication method that verifies a user's TLS Client certificate using the provided CA Certificate, allowing secure access to Infisical resources.
+
+## Diagram
+
+The following sequence diagram illustrates the TLS Certificate Auth workflow for authenticating users with Infisical.
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Infisical
+
+ Note over Client,Client: Step 1: Setup your TLS request with the client certificate
+
+ Note over Client,Infisical: Step 2: Login Operation
+ Client->>Infisical: Send request to /api/v1/auth/tls-cert-auth/login
+
+ Note over Infisical: Step 3: Request verification using CA Certificate
+
+ Infisical->>Client: Return short-lived access token
+
+ Note over Client,Infisical: Step 5: Access Infisical API with token
+ Client->>Infisical: Make authenticated requests using the short-lived access token
+```
+
+## Concept
+
+At a high level, Infisical authenticates the client's TLS Certificate by verifying its identity and checking that it meets specific requirements (e.g., it is bound to the allowed common names) at the `/api/v1/auth/tls-cert-auth/login` endpoint. If successful, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API.
+
+To be more specific:
+
+1. The client sends a TLS request with the client certificate to Infisical at the `/api/v1/auth/tls-cert-auth/login endpoint.`
+2. Infisical verifies the incoming request using the provided CA certificate.
+3. Infisical checks the user's properties against set criteria such as Allowed Common Names.
+4. If all checks pass, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API.
+
+
+ Most of the time, the Infisical server will be behind a load balancer or
+ proxy. To propagate the TLS certificate from the load balancer to the
+ instance, you can configure the TLS to send the client certificate as a header
+ that is set as an [environment
+ variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key).
+
+
+## Guide
+
+In the following steps, we explore how to create and use identities for your workloads and applications on TLS Certificate to
+access the Infisical API using request signing.
+
+### Creating an identity
+
+To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**.
+
+
+
+When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles).
+
+
+
+Input some details for your new identity:
+
+- **Name (required):** A friendly name for the identity.
+- **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to.
+
+Once you've created an identity, you'll be redirected to a page where you can manage the identity.
+
+
+
+Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use TLS Certificate Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown.
+
+
+
+
+
+Now create a new TLS Certificate Auth Method.
+
+
+
+Here's some information about each field:
+
+- **CA Certificate:** A PEM encoded CA Certificate used to validate incoming TLS request client certificate.
+- **Allowed Common Names:** A Comma seperated list of client certificate common names allowed.
+- **Access Token TTL (default is `2592000` equivalent to 30 days):** The lifetime for an access token in seconds. This value will be referenced at renewal time.
+- **Access Token Max TTL (default is `2592000` equivalent to 30 days):** The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.
+- **Access Token Max Number of Uses (default is `0`):** The maximum number of times that an access token can be used; a value of `0` implies an infinite number of uses.
+- **Access Token Trusted IPs:** The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address.
+
+### Adding an identity to a project
+
+In order to allow an identity to access project-level resources such as secrets, you must add it to the relevant projects.
+
+To do this, head over to the project you want to add the identity to and navigate to Project Settings > Access Control > Machine Identities and press **Add Identity**.
+
+
+
+Select the identity you want to add to the project and the project-level role you want it to assume. The project role given to the identity will determine what project-level resources this identity can access.
+
+
+
+### Accessing the Infisical API with the identity
+
+To access the Infisical API as the identity, you need to send a TLS request to `/api/v1/auth/tls-cert-auth/login` endpoint.
+
+Below is an example of how you can authenticate with Infisical using NodeJS.
+
+```javascript
+const fs = require("fs");
+const https = require("https");
+const axios = require("axios");
+
+try {
+ const clientCertificate = fs.readFileSync("client-cert.pem", "utf8");
+ const clientKeyCertificate = fs.readFileSync("client-key.pem", "utf8");
+
+ const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL
+ const identityId = "";
+
+ // Create HTTPS agent with client certificate and key
+ const httpsAgent = new https.Agent({
+ cert: clientCertificate,
+ key: clientKeyCertificate,
+ });
+
+ const { data } = await axios.post(
+ `{infisicalUrl}/api/v1/auth/tls-cert-auth/login`,
+ {
+ identityId,
+ },
+ {
+ httpsAgent: httpsAgent, // Pass the HTTPS agent with client cert
+ },
+ );
+
+ console.log("result data: ", data); // access token here
+} catch (err) {
+ console.error(err);
+}
+```
+
+
+ Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted.
+
+ If an identity access token expires, it can no longer access the Infisical API. A new access token should be obtained by performing another login operation.
+
+
diff --git a/docs/images/platform/identities/identities-tls-cert-auth-create-auth.png b/docs/images/platform/identities/identities-tls-cert-auth-create-auth.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e66633e71b7a1988638b500b0ad15593e6a774c
GIT binary patch
literal 478524
zcmbrm1z6MlyFU&HfR>Uj^Y;yT_@O7&+(}dKqdw23fng
zh+92#duk)@(di?9M#8v*^MZBC8uNrD-
z^WSmvu;G^xzaf6(suF;opI^b_nJq}??)`s0PCZk+YVYOc4w8_7!C>MrNpUw1JBgd}
z^70ZlZb{s_B}OeF=IQI|W#uF0>Ur%SmHexoyEdNI9uDqa4sNdezv{Jm>IU^vyn6Ll
zL;wE#qn}<5w*S?VtLHz5MIE5TuR9Vq#cxRbyKd^E3cs#`v^*Sas4f4hU+Jd8?~?y<
z?Vs%^Nc?L2zjfvxBmI4qI#eZqg2cb4O$l((e43GlMuq0#-P?LTw97au7{z2RqpGA_2!5&0>_3&Avo
zx9L6~YYNc}5@b(UPR%M2qNJsz^C!}c{WcVPD=E@(&Mj#Ci`it44l^y1TzjmGU*MfP
zrKrzezQh)L!bO0t`T@&F!#>&bbPIQRHNEo!=+%#gfjO(u8&8xkn#?r84H67Azp$2Z
zG4Th}(EYWaxDb_w)72pwqvu{;3HhrJ|7UY*K$Ygj2X#gl|6&*Zvlgbf4>Yt$4-mKW
zqf_)zBMc%+)$}QCSI?@@(4L>Fh#aJ$rDvla|HU66zK}r1qJ>N5N{q2$w=
z3SzUiVkBOIZMMf`k1{Ob#@;yj*L(F}PlN5D
z8_{Z0pPAm#$={58&s%sai2HBV
zTgpXCr|&=T)R-P&7R>&jDuZ?zo(uTwv#GzBWsUwjqtD!;c2eD~oBRJryN?-mE|zAW
zyPtGfOUB87uDuUGV-2gK6HxsdboQ?WDPRi;JXr*MwmSOwAG8X*8|-cueo08?{J)C)
z6{6EjRUy5?Uh`hKd+PJk=UmYprlJXNFYk7oullfj`qy%(v;#ir2opk&19%xg*bBXTSAJ}Fx_OZxpfOL99bO-vBA_iU;f=nJb)grn?U=vj+?8dKC@8o
zO?VM|wfg8L@Sau8-&n$e8zCE~4GRs=roG?HPzPK}o4Sl$EHKwikrx|!sF>UIe%W*V
zL(%T&L1eyTM5wNGWHPU!amf3oD)kK`zsmb_wK9P)qJ--yq>siMnfPt+Mj2t&BKGY
z^H))$^R{g4`lEUarq8c3%4!MgKBPW&H^%6?)~b4VD3wnJ9_Qx)9b(WbPn;UJD+bNA|bo6&o=-1Ce}nWQ@Q(HEzQM;&*LZ@p2SU|xwj&g
z&^Z6DdMbAg!JS%TL4)(vlYsH#`){vVLWCn61=;rva@=OpyO(Dea_bIy{j#VcuwYZ#J?t-{rt<0
z*_HX8B~*UCtW$YJ#C>uL<0_huPr==dFlqBm8RCcN$ao{Rk0#L$yaCwH25z9Ju!n
z36qZna7aRmYfq>F#|Y5{<~|jDX9Uv?%enFVuBIL)Ri^pW39+%;xBchRg&1z=y?5`
zi2uZo;N8sbrGr#98}HeEhWcXTpA}1SGt$e1tU4EGrynBo9csjR$3oHuP%_se^KJ?8z%B}6rRM*2B93nJy@;7}Dj^qw-^!8b9!
z=vQ^Kw23+(X6k^vLq7lUC&u!-2;_>kHSe|K)!Sq#+`Wy=M^;Db=L!ln>^9#68@+-#
z@#+sqHuCv>pK{Hr&VwIXK&?)zWNdy7vLH}l}-C9UAlzMkz~
z5R82@A-;%goz2PVoAzHZ%%tBCn%x!
z4gL9Hld%yD##~lfdX9DfV&!IX5*;l)P$Rf6S{?jUd^bqzj~%#%k&_j{P~ra9x(!xE
zlh^;+_WvKe;y+KXb7qD@UWFJPOar{gUna)RikLv(>jqZ4jdu*i$EBnz83wz^?%!Vbo3+Fr%zk2pQ9Iu+Plg6OT6C?NuRt7+x+ro
z3T$0MQI?YW$&T715;Dv@Hr-2v^=Krim8M>4+z>Cs!u|;NK}FZ|!xD)EEf(uRWo5M#df+xX5h)ik
zm{1Pn${PZ#^z5J^H@Ygjz6^A}R{F`ln2n66y@t|~({3CSnKpdn-hTOjql3L*n78+r
zH!&KQu5X8o%gd(|5_U$d9b*`ScDqZ_9(x{gBa4d&
z3W3YwMah|sKU{g61Hg-#KNhu7Qy<*`(!2)9GIf`c5{rt(vw>Fm%@Zzle%n!yF+W1k
z-dq;%X70p5!~Ne%m1>BjT_moZnajCP5})sHvp^(1=QAvwr>w$Hx3c-CWe`PlRpJpC
z%1RI$q7z`$Xm>?~2V)zu@8Oyexc>@S++*6I=(E=Bs-T_h=sdTqEso9O5iqUBGuPi%
z!N_>5q^9XQtsIf@==iz#fDbo{f>pL^Tom@Ro40Z=m4B
z&L{(q8JP1G1a3N7Xt5*)MMi3!v2(Guw$5=HM|i)JewxokS*0`%z9&lu1qLc2ukE$A
zwqg=I`;(GM;>6><3^*&$U=NJqGPYGyi=_Mju_HSheL^s%X}};%}MT3k6sNMM_#43q6d`wcGyo?OV|tBhT~4$GiQXDf;cA$oKJ$
z;v?$%h=@2Ws9WAP{)ukvYyz02PSDx3Lecak$*Urjephutlzi0ZzUaeM7vEmu9eCCL8FSxcOis|@cFh#}
z$xzI;|E+OHXC;e!^?A)W2Xjoo+++_HssgN9TCHCI`TR+xH1%1?L8FX!W5;4_95Ja=sC{sjQ4aU(;KnB;vmEsymq7j1Ja4HEW
zBC3S%%I$#QYBOQUuV26ZX?7P5KYqnzcwOe)%KB`FFL_<)Bto)c^^g4R+@kZ?7-c0U
zkgM7V{`>c*X_NSxO#8g#&pj!!6Zqq!7?-s9?9KJxR?F10Q$ip8|Eb)(=hclQk&Djt
zNSxuTE0mq%nNfuL2^CD{PU*Vp+LRE97s*pCT6H-SQ&XAf0HTiE0pIcNL})LJL>UTF
zajqK7*JPwEW;}Dbwyv&brF+Z@HhtimZ~4*AMWI_TbDRBLc
ze!OA0{K`b#EC>v4>yc9iEed=Q4E2<_^z}m&TLG0$qf``szL_w@yG!mU`n=N@zP%K-
zd~Nd=VDLYv?tfmRoGmDOn|cgWmb~1dpxF2vHmjM2E0%Rl~{zzE{>+&VE_3+51t~MyR
z!OC2hr8PFvmQU3zf=K^i==Pp#G{uB#gxh7NC&&lsu=OC#eFtG6T(
zbM4qqlM7KCfaN008_OK^
z>+o+_-fXYroKg0w(my3-G1xH98JOD?NRh$+p{DdWL$UrOacPOA#sph3!X4D#m)AC)
z6%ukZHM%<|+&_C_0hCZ;u_|skG&*Y8RCY3~DSa}1D&RY`7{v%19LHiqn(msZQvCP2j>0
zoA}j;NG4Sd+!StH7V_C8D=tg3YLYdToMTQEW<4{j(%>8-*81)k-_Ff`Fl
zvNL%MGmhjrTx%ql7@LGz=PZq?77w9_CN7{f#sMdCFN!r^`x7eGro`swWxJjnYlK5K
z?Jxu7FbB`cU4my1!UQboFjUb}CN3y>f<{>_u1M53CTkJW-Zm*kCnxieE;v6sJo!qq
zgmCQ|p|vkFAmdftAYc2E$6-Ui?@ZIR#Ds*2?>f`R!P$K*8c6box5(qy$Qtzs(3u}w
zCJ4hSRfwWq6@imdoa^}rpK^5R8|)I*w#aY#cqozDi(%ZttTm|}dOz!N8!V_#9?l}8
zlIJu0!CV2}MDTUf^f$~S=P#~A5^E}tHJU1G7luhSX_!rC+7^#6c=d4p^uV00z*mJq
zJBLS)oZQ7bocDX^n$VJ-&(a-Iw$g_%NXKWBfqjpACI6gVP4y758n!Gh>{#tOzX8iW
zg_YIx@(UIlDRaZN{nGq*yapSCjFQ#w#k*F=Qwq0mqa!=AG70ma+#Fm_fr}j5^lYYw
zi&Lgc2L{f^qte?m_j)X&!cVb~qRwcZL9I
z7lJB4bO}Ux9{5~myn92sp+!&T2|}A%%*5`f&v0#;xzHNBi-dWDdpIb`5kzVuY>NmZ`TfAvrpSy6X?^a|i@EW&|`
zQ(Re)E1E7pnu(U!j?=fL+h>326|HbktP9{DU*A|8X+C#WCG%x4=m_Zn$!gY&H6qIc
zIrUyTIy!qa9Ul3=QnvJNdSzyow({l8l98m4XjXp+s7^$CSwv%dYipdmV?*hsh&k;q
zFhfxzvcgA=D3|e~{H=JE@|ySWNQGu=Gz6wHRUb%aSX~8cbfmg
zg*~;^c5IE?nBOhPJ!xaw%Z*RG?u1gvO=o)ZzdBFrn&E+$;wd@X4UmU}bq8sXdm@!9
zlbFk}+5PPzPZ877VhVB4Ze^^3kaXLmkG*LB10-*0;j7-e??McfKlDilKV((~XfAe_
zZfo@oL;dz`fQ|T+aJT)ng@KaWD&L#bz+iB8G;_L$d)8)gv9wk^=80Jb3Y`&jG}f5r
zU*lp9!Ea{7>JEC7TsaoREy~~b#~!O>rf>)aY%jSr*)*=|U6eV0)G4~30Lj`U*_o6y
z>Gzo>0#Bxu^Kk4!iJo(N6>b?8`zBjdAvyRB!m9|U^nglN``PJq1>NCxf$|8+^$m|B
zbOn5CK+R|xG0~13w6A=x=GCBl9|O?$y43d@A%3erHQ$qBQ>7x2dzE&mMEh}8nhjE5-4{t~O(_I@CUz!DQme${9f?jj9-=e@l+m+1)cN*W*_fS%->
zGq^;H!>&2~D++L{A8BBJagcBC=0}E~YH275j;~}50=A=C8OnetbO41fDDC~6<)6|m
zGLEsxyUcMx=ehxYvN5H`>Bqg}TdO%J7zc2DB)yJOF!c?G=96atGfOjIkGu$P(XiE&
z&{d}%B+tDqhWMcBMLuj4dJ99@4xS}Z*5%3SqK1J+#^A%kVGQUB~1ZR*a=&B
z-HT2k7-N!mZZC~kxDwQGdqFi~u0vv#${5o1&!0M(28MAsj=-et3E0k$YT%39ruKF(
z!bogvtjiGQnXEPJ5*eGr>Va0gZzMAR8&Yn5byoX&LYw{KD2nErCOmw2Xm~Q2gFC$%
zAU)t7KaU+9bw3l-skh!=IuRAiJ&!%lth~3Xz%sZwq0UQ{GdE5fIBU+w5zG8VJIM;k7U{f*Lg6B)2%%n+u&Txx6lLB-
zb&`7Hq+B4#Nrw3T@1E$)`gpqV0q^CkejwopDJ`2WnB+%W^aC3U7`QH
zL$*=S!gvm4arx+6cT-(b;b<-ozm$v(-Lq@@Bx!Ec`}k010LTF!IH+U+Kq&5AY3K7q
zYTb*uT_$hQ7(fczJ?fZRr78s9lJz;tviN5QZDoq)J=fEgui4)*Ts-_MT;whUlUF
z4G0=}*=fpdlg7E153AQTrj^pH0eN~lA|rj#rgs|idoASn=s!~F#t8DtZ~Y*g3Z`4H
zwpCdsXfp4zJ0gN%EeOrM;~(X&ki*H*2IGiU?ISAmu<~5Iz%dd2aNh;#K2#sbXOGj$
zP+*}#7y`ElZBzZ)yYg#0F8k=aOY0igk|<~kez4okOIeA$KDOx<4#u&6u1O*$xnFdf
zY*-JARzhsdIxq+CRoB-3{8BWN(Ao_!UX(P_XhM6!^E)M&EOG_Q@XL}d8ux>0c-}lV
zHXNo913#EuadSF(`On2Sgxq5LI+0L+T1EgbkgDW#JNz*`H{e5Rfz|lPV4L&`*+Mo9
zV=ysDzRNpVK9;nyvQkjm6v_k%TT{WA#rkmoy|ctRChPhvRIAZj&|jk>
zBTw@kK48;D4hi~lxYu`l)_bSd3w2shc=wZyNa>v5T9)8({b&wo7LonZ4a|_~McyLB
z_WA^2HoeI^_7q}%MktUi=wkvBVX?Qewvs>n8yTFkr@wr#)tq)w>HLci>iT>Pd=8^2COYz}?KG-c(*zl=2~+Uea>3L=&Km
zhC66)BX^DaQO9%3Ol5^*j!z|J!3}PJiGwYEaCg4?6Q#Hkb(1Pifd__`s?$Z$oXcw(
zA%|3@9C(2cX;vRFz>=XK+|97+DX$!KptfdAs}LR)K-odBPP7JqZYayIe-RccK-dQ*
z55#(UzYby%Aj|UiGpE%IIE^JrnAb1KqUu79LM+Fp8qZbaqjy8b;&d67UH&NI5Ot0@
zKAj=Yt8YJ4wD-0zUL>*DQ4{njZ#GB02tUzfnnFNr;LtVt@k{3+
zZyD9Izf4~RWh16;gA!)OdEac(A$f#c4Y`ayn<|@h{3+Y9S$Xb-it^r16t15?P?9lC
znNSS+ex7-PN|7B@ifjrA3E^MMo8DL4qs(^Po|699tAxy-d|Y?df3<)Pl9ku|bOLBr
zNR$*Z7SqZg@2B|^zvuD?57}nWiWQ5wexrM^m)vT!pEeK2ii?Rc%3iDUCM5^Pd=6C(
z%ooMF2M&vgkNTUh_7zt<$o4Zu?9qZ1_WSNa>YN-kE$)b#j5!How-8
z=1<@h&CCOsI{G!8lyw~2e@v{VDR2XPx`(vZ)X+d*M_>F}kai3x6<)}r;bi%95VeqO
z$YZxm59}>$`j;;smzMgxWJKCDM864apBb)A?2{#lcU?D9V7}M<=I!ejfclDklcmfB
zEYH$|Ij}}mO_dR$#K)(`K||M?_i_%eHiRTh6%}+#H*$NsVlFFs7?hX~y)&!wCu2^5
zGaXL4L;@iNg9taO%!zmtqeZ{8hRx|kQ5@=1c4YCkuqi!)Aw#10T~IgTJhbxM{VeF7
zrb|%6iaG|H2hbfzD4DJ1J04#VNS83pUA!^?I86o6ji!M;C7NLy0jrJ5d+qCn^K?7I
z;~E(YhpXX!;C=HJ$v5nTw-KP>a`rg#KPAFrG#>N+Xs6RE%Po{_)2MWS`0bvyFcXR{c#+8V?3|k~$Ej)>QGpSn{vZakMy(1Z9h*1laNVMsyNGbdtG|2u;drlPoSbAXhuE10Y>k-H;pxK^^GaYVt?jO!@
z`E(*$o*8!rQb;f?Mhae0ddI@T&F5Ok4NDsfI4^HfW_l}C*|{i2iR6QO@!=bJ=&x-V
zhp?JO3r}!p+!y-!X$n$#c!ZthA0>#$ZIj87v?6?%^}&v7Rw69AwR7+z0$-5Vu=UnK
zA6IgsE($VC2WTkhledGO2o9^%ja@Vc-JlXuNz>7MeUS%J*0F*|M(E}H)x!cO@~VeY
z2fL2%-bOfsT(6spV|Q<1M5)I>P>lsgJ7#|oZ`2fdP!0m$y4^k1uqO|W%O_$EcY@q?
zPrzMJn|a^q^jnS+1_jrk9jl+
zrWor_{c%ACd+NRQg%7fVhdi+n6J3jOVg|H!7JCoz{s6=HM~{woYR=qs0sz$7AVEA3
zj>j2^E<5;BTWE_bH!Y-*u1f-9$uc&p0~2|Hhw_5KAE|)%pNk0zndKCkxVBzf(>;vG
z8U#)Q`zD9Z!)8lrVCw6$zIQE~=x#2Z$_1NH@`Cj$3ulwg7&`2ZN!sBm2z{*&^$vmK
z8&IXeT?6mLT@eG6q1~vLVDW-NT_4AWjva9#@5YBSh8zGH2PNpT&L*#$XJW6&4SG+ZvmT3Mu*nu%ChUoQITnjYnJy*XJFV1EUK(d#4g#Oc_i
zc{s6f+WSf9G^%53>g(Uj>bbLHQJpuF5&)=cZ6n@qs!L1R^f{JD#8YZ}f&;4e|Hd#&Ux&xqA42BoSpDJke#
zWYmQOBR-opxVLD{Pr{ECWvxdDh}usb0oWNqO#_ohk!_WH$Lo9<^&{4^`tr64O(7Z2
z^E*6?bXA48ngO#Q#8Xz_Jyx5AcM`ihCta2X@{I*+10vt
z(vWqoadU=xj}%~`O`0qsJ7qS{*Q*H=qCA(-Mw-M5Vqf)vP$+UaT=CT__mW!{p&1
z%=*HJ3d!@YHwT49=dk1B2!nKX2=mNp9?q3$IHK&gcgto3EtD8Y-b=#i=0&k2zoMWrh(!){F2ktpBIZIvD%C~L9cG{
zCO{)s?3>kL2{F}az{ksZlI5yO;Hm _biR+2u+x35hwg7oZVRzL)VIH4Zp|SjTNBCc
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