diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4221eadcb..8ff12069a 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -52,6 +52,7 @@ import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-acces import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -162,6 +163,7 @@ declare module "fastify" { identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; + identityJwtAuth: TIdentityJwtAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f5c44ff79..9630429b8 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -98,6 +98,9 @@ import { TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, + TIdentityJwtAuths, + TIdentityJwtAuthsInsert, + TIdentityJwtAuthsUpdate, TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, @@ -590,6 +593,11 @@ declare module "knex/types/tables" { TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate >; + [TableName.IdentityJwtAuth]: KnexOriginal.CompositeTableType< + TIdentityJwtAuths, + TIdentityJwtAuthsInsert, + TIdentityJwtAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts new file mode 100644 index 000000000..03594b77c --- /dev/null +++ b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts @@ -0,0 +1,34 @@ +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.IdentityJwtAuth))) { + await knex.schema.createTable(TableName.IdentityJwtAuth, (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.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("configurationType").notNullable(); + t.string("jwksUrl").notNullable(); + t.binary("encryptedJwksCaCert").notNullable(); + t.binary("encryptedPublicKeys").notNullable(); + t.string("boundIssuer").notNullable(); + t.string("boundAudiences").notNullable(); + t.jsonb("boundClaims").notNullable(); + t.string("boundSubject").notNullable(); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.IdentityJwtAuth); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityJwtAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityJwtAuth); +} diff --git a/backend/src/db/schemas/identity-jwt-auths.ts b/backend/src/db/schemas/identity-jwt-auths.ts new file mode 100644 index 000000000..1d3ea9c03 --- /dev/null +++ b/backend/src/db/schemas/identity-jwt-auths.ts @@ -0,0 +1,33 @@ +// 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 IdentityJwtAuthsSchema = 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(), + identityId: z.string().uuid(), + configurationType: z.string(), + jwksUrl: z.string(), + encryptedJwksCaCert: zodBuffer, + encryptedPublicKeys: zodBuffer, + boundIssuer: z.string(), + boundAudiences: z.string(), + boundClaims: z.unknown(), + boundSubject: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityJwtAuths = z.infer; +export type TIdentityJwtAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityJwtAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 74741a8ff..bd26610d9 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -30,6 +30,7 @@ export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; +export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; export * from "./identity-oidc-auths"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 171931f7e..5ec686140 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -68,6 +68,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOidcAuth = "identity_oidc_auths", + IdentityJwtAuth = "identity_jwt_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -196,5 +197,6 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-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 601436d1b..d3b865665 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -95,6 +95,11 @@ export enum EventType { UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth", + ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", + UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", + GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", + REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", @@ -903,6 +908,67 @@ interface GetIdentityOidcAuthEvent { }; } +interface LoginIdentityJwtAuthEvent { + type: EventType.LOGIN_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + identityJwtAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityJwtAuthEvent { + type: EventType.ADD_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityJwtAuthEvent { + type: EventType.UPDATE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface DeleteIdentityJwtAuthEvent { + type: EventType.REVOKE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + +interface GetIdentityJwtAuthEvent { + type: EventType.GET_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -1742,6 +1808,11 @@ export type Event = | DeleteIdentityOidcAuthEvent | UpdateIdentityOidcAuthEvent | GetIdentityOidcAuthEvent + | LoginIdentityJwtAuthEvent + | AddIdentityJwtAuthEvent + | UpdateIdentityJwtAuthEvent + | GetIdentityJwtAuthEvent + | DeleteIdentityJwtAuthEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 711837326..3c64dc60b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -351,6 +351,52 @@ export const OIDC_AUTH = { } } as const; +export const JWT_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A list of PEM-encoded public keys used to verify JWT signatures. Required if configurationType is 'static'. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The unique identifier of the JWT provider.", + boundAudiences: "The list of intended recipients.", + boundClaims: "The attributes that should be present in the JWT for it to be valid.", + boundSubject: "The expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + 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." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The new PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A new list of PEM-encoded public keys used to verify JWT signatures. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The new unique identifier of the JWT provider.", + boundAudiences: "The new list of intended recipients.", + boundClaims: "The new attributes that should be present in the JWT for it to be valid.", + boundSubject: "The new expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + 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." + }, + 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 ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 806b3575c..df1d83dda 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -121,6 +121,8 @@ import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/i import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-dal"; +import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; @@ -298,6 +300,7 @@ export const registerRoutes = async ( const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const identityOidcAuthDAL = identityOidcAuthDALFactory(db); + const identityJwtAuthDAL = identityJwtAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(auditLogDb ?? db); @@ -1184,6 +1187,15 @@ export const registerRoutes = async ( orgBotDAL }); + const identityJwtAuthService = identityJwtAuthServiceFactory({ + identityJwtAuthDAL, + permissionService, + identityAccessTokenDAL, + identityOrgMembershipDAL, + licenseService, + kmsService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -1347,6 +1359,7 @@ export const registerRoutes = async ( identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, identityOidcAuth: identityOidcAuthService, + identityJwtAuth: identityJwtAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts new file mode 100644 index 000000000..d60bb969d --- /dev/null +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -0,0 +1,386 @@ +import { z } from "zod"; + +import { IdentityJwtAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { JWT_AUTH } from "@app/lib/api-docs"; +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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types"; +import { + validateJwtAuthAudiencesField, + validateJwtBoundClaimsField +} from "@app/services/identity-jwt-auth/identity-jwt-auth-validators"; + +const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ + encryptedJwksCaCert: true, + encryptedPublicKeys: true +}).extend({ + jwksCaCert: z.string(), + publicKeys: z.string().array() +}); + +const CreateBaseSchema = z.object({ + boundIssuer: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), + boundSubject: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) +}); + +const UpdateBaseSchema = z + .object({ + boundIssuer: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.UPDATE.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.UPDATE.boundClaims), + boundSubject: z.string().trim().default("").describe(JWT_AUTH.UPDATE.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.UPDATE.accessTokenNumUsesLimit) + }) + .partial(); + +const JwksConfigurationSchema = z.object({ + configurationType: z.literal(JwtConfigurationType.JWKS).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().trim().url().describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().array().optional().default([]).describe(JWT_AUTH.ATTACH.publicKeys) +}); + +const StaticConfigurationSchema = z.object({ + configurationType: z.literal(JwtConfigurationType.STATIC).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().trim().optional().default("").describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().min(1).array().min(1).describe(JWT_AUTH.ATTACH.publicKeys) +}); + +export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/jwt-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with JWT Auth", + body: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityJwtAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityJwtAuthId: identityJwtAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach JWT Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + JwksConfigurationSchema.merge(CreateBaseSchema), + StaticConfigurationSchema.merge(CreateBaseSchema) + ]), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.attachJwtAuth({ + 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: identityJwtAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identityJwtAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.UPDATE.identityId) + }), + body: z.discriminatedUnion("configurationType", [ + JwksConfigurationSchema.merge(UpdateBaseSchema), + StaticConfigurationSchema.merge(UpdateBaseSchema) + ]), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.updateJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "GET", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.getJwtAuth({ + 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: identityJwtAuth.orgId, + event: { + type: EventType.GET_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema.omit({ + publicKeys: true, + jwksCaCert: true + }) + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.revokeJwtAuth({ + 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: identityJwtAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index f9edfc18c..a04f77b7a 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -12,6 +12,7 @@ import { registerIdentityAccessTokenRouter } from "./identity-access-token-route import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; +import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; @@ -54,6 +55,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); + await authRouter.register(registerIdentityJwtAuthRouter); }, { prefix: "/auth" } ); 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 f12bd8c15..57517c706 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 @@ -37,7 +37,7 @@ 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`) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select( db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), @@ -47,6 +47,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"), db.ref("name").withSchema(TableName.Identity) ) .first(); @@ -61,7 +62,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure, trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s, trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc, - trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken + trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken, + trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt }; } 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 a59d1e959..47d1791d2 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 @@ -171,7 +171,8 @@ export const identityAccessTokenServiceFactory = ({ [IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth, [IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth, [IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth, - [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth + [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth, + [IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth }; const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod]; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts new file mode 100644 index 000000000..5e6d13be6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityJwtAuthDALFactory = ReturnType; + +export const identityJwtAuthDALFactory = (db: TDbClient) => { + const jwtAuthOrm = ormify(db, TableName.IdentityJwtAuth); + + return jwtAuthOrm; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts new file mode 100644 index 000000000..57aa933d6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts @@ -0,0 +1,13 @@ +import picomatch from "picomatch"; + +export const doesFieldValueMatchJwtPolicy = (fieldValue: string | boolean | number, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts new file mode 100644 index 000000000..5f8fc5ff6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -0,0 +1,534 @@ +import { ForbiddenError } from "@casl/ability"; +import https from "https"; +import jwt from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; + +import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, 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 { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; +import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns"; +import { + JwtConfigurationType, + TAttachJwtAuthDTO, + TGetJwtAuthDTO, + TLoginJwtAuthDTO, + TRevokeJwtAuthDTO, + TUpdateJwtAuthDTO +} from "./identity-jwt-auth-types"; + +type TIdentityJwtAuthServiceFactoryDep = { + identityJwtAuthDAL: TIdentityJwtAuthDALFactory; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; +}; + +export type TIdentityJwtAuthServiceFactory = ReturnType; + +export const identityJwtAuthServiceFactory = ({ + identityJwtAuthDAL, + identityOrgMembershipDAL, + permissionService, + licenseService, + identityAccessTokenDAL, + kmsService +}: TIdentityJwtAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + if (!identityJwtAuth) { + throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityJwtAuth.identityId + }); + if (!identityMembershipOrg) { + throw new NotFoundError({ + message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found` + }); + } + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const decodedToken = jwt.decode(jwtValue, { complete: true }); + if (!decodedToken) { + throw new UnauthorizedError({ + message: "Invalid JWT" + }); + } + + let tokenData: Record = {}; + + if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + const client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + + const { kid } = decodedToken.header; + const jwtSigningKey = await client.getSigningKey(kid); + + try { + tokenData = jwt.verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } + } else { + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + const errors: string[] = []; + let isMatchAnyKey = false; + for (const publicKey of decryptedPublicKeys) { + try { + tokenData = jwt.verify(jwtValue, publicKey) as Record; + isMatchAnyKey = true; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + errors.push(error.message); + } + } + } + + if (!isMatchAnyKey) { + throw new UnauthorizedError({ + message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` + }); + } + } + + if (identityJwtAuth.boundIssuer) { + if (tokenData.iss !== identityJwtAuth.boundIssuer) { + throw new ForbiddenRequestError({ + message: "Access denied: issuer mismatch" + }); + } + } + + if (identityJwtAuth.boundSubject) { + if (!tokenData.sub) { + throw new UnauthorizedError({ + message: "Access denied: token has no subject field" + }); + } + + if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: subject not allowed" + }); + } + } + + if (identityJwtAuth.boundAudiences) { + if (!tokenData.aud) { + throw new UnauthorizedError({ + message: "Access denied: token has no audience field" + }); + } + + if ( + !identityJwtAuth.boundAudiences + .split(", ") + .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: token audience not allowed" + }); + } + } + + if (identityJwtAuth.boundClaims) { + Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + + if (!tokenData[claimKey]) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if ( + !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) + ) { + throw new UnauthorizedError({ + message: `Access denied: claim mismatch for field ${claimKey}` + }); + } + }); + } + + const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityJwtAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.JWT_AUTH + }, + tx + ); + + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityJwtAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to add JWT 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(OrgPermissionActions.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: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + const identityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const doc = await identityJwtAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + configurationType, + jwksUrl, + encryptedJwksCaCert, + encryptedPublicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + + return doc; + }); + return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; + }; + + const updateJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateJwtAuthDTO) => { + 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.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to update JWT Auth" + }); + } + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityJwtAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityJwtAuth.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(OrgPermissionActions.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 updateQuery: TIdentityJwtAuthsUpdate = { + boundIssuer, + configurationType, + jwksUrl, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } = + await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + if (jwksCaCert !== undefined) { + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + updateQuery.encryptedJwksCaCert = encryptedJwksCaCert; + } + + if (publicKeys) { + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + updateQuery.encryptedPublicKeys = encryptedPublicKeys; + } + + const updatedJwtAuth = await identityJwtAuthDAL.updateById(identityJwtAuth.id, updateQuery); + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...updatedJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const getJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetJwtAuthDTO) => { + 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.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT Auth attached" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...identityJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const revokeJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new NotFoundError({ message: "Failed to find identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + if (!isAtLeastAsPrivileged(permission, rolePermission)) { + throw new ForbiddenRequestError({ + message: "Failed to revoke JWT auth of identity with more privileged role" + }); + } + + const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); + + return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + + return revokedIdentityJwtAuth; + }; + + return { + login, + attachJwtAuth, + updateJwtAuth, + getJwtAuth, + revokeJwtAuth + }; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts new file mode 100644 index 000000000..a6881f0e5 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -0,0 +1,51 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum JwtConfigurationType { + JWKS = "jwks", + STATIC = "static" +} + +export type TAttachJwtAuthDTO = { + identityId: string; + configurationType: JwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateJwtAuthDTO = { + identityId: string; + configurationType?: JwtConfigurationType; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetJwtAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeJwtAuthDTO = { + identityId: string; +} & Omit; + +export type TLoginJwtAuthDTO = { + identityId: string; + jwt: string; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts new file mode 100644 index 000000000..515c2ac7e --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const validateJwtAuthAudiencesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validateJwtBoundClaimsField = z.record(z.string()).transform((data) => { + const formattedClaims: Record = {}; + Object.keys(data).forEach((key) => { + formattedClaims[key] = data[key] + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + + return formattedClaims; +}); diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 49cf4d119..2d77e6544 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -7,7 +7,8 @@ export const buildAuthMethods = ({ kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }: { uaId?: string; gcpId?: string; @@ -16,6 +17,7 @@ export const buildAuthMethods = ({ oidcId?: string; azureId?: string; tokenId?: string; + jwtId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -24,6 +26,7 @@ export const buildAuthMethods = ({ ...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null], ...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null], ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], - ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null] + ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], + ...[jwtId ? IdentityAuthMethod.JWT_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 bbdf96a2b..92a6795d0 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -6,6 +6,7 @@ import { TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, + TIdentityJwtAuths, TIdentityKubernetesAuths, TIdentityOidcAuths, TIdentityOrgMemberships, @@ -70,6 +71,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -81,6 +87,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), 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("name").withSchema(TableName.Identity) ); @@ -183,6 +190,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityJwtAuth}.identityId` + ) .select( db.ref("id").withSchema("paginatedIdentity"), @@ -200,7 +212,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), - db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -237,6 +250,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { uaId, awsId, gcpId, + jwtId, kubernetesId, oidcId, azureId, @@ -271,7 +285,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }) } }), diff --git a/docs/api-reference/endpoints/jwt-auth/attach.mdx b/docs/api-reference/endpoints/jwt-auth/attach.mdx new file mode 100644 index 000000000..f2905f1a0 --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/login.mdx b/docs/api-reference/endpoints/jwt-auth/login.mdx new file mode 100644 index 000000000..c037fbf7f --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/jwt-auth/login" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/retrieve.mdx b/docs/api-reference/endpoints/jwt-auth/retrieve.mdx new file mode 100644 index 000000000..8100ef843 --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/revoke.mdx b/docs/api-reference/endpoints/jwt-auth/revoke.mdx new file mode 100644 index 000000000..13a61475a --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/jwt-auth/update.mdx b/docs/api-reference/endpoints/jwt-auth/update.mdx new file mode 100644 index 000000000..8a53907ab --- /dev/null +++ b/docs/api-reference/endpoints/jwt-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/jwt-auth/identities/{identityId}" +--- diff --git a/docs/documentation/platform/identities/jwt-auth.mdx b/docs/documentation/platform/identities/jwt-auth.mdx new file mode 100644 index 000000000..3dcf12b29 --- /dev/null +++ b/docs/documentation/platform/identities/jwt-auth.mdx @@ -0,0 +1,169 @@ +--- +title: JWT Auth +description: "Learn how to authenticate with Infisical using JWT-based authentication." +--- + +**JWT Auth** is a platform-agnostic authentication method that validates JSON Web Tokens (JWTs) issued by your JWT issuer or authentication system, allowing secure authentication from any platform or environment that can obtain valid JWTs. + +## Diagram + +The following sequence diagram illustrates the JWT Auth workflow for authenticating with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client Application + participant Issuer as JWT Issuer + participant Infis as Infisical + + Client->>Issuer: Step 1: Request JWT token + Issuer-->>Client: Return signed JWT with claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed JWT to /api/v1/auth/jwt-auth/login + + Note over Infis: Step 3: JWT Validation + Infis->>Infis: Validate JWT signature using configured public keys or JWKS + Infis->>Infis: Verify required claims (aud, sub, iss) + + Note over Infis: Step 4: Token Generation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a client by verifying the JWT and checking that it meets specific requirements (e.g. it is signed by a trusted key) at the `/api/v1/auth/jwt-auth/login` endpoint. If successful, then 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 requests a JWT from their JWT issuer. +2. The fetched JWT is sent to Infisical at the `/api/v1/auth/jwt-auth/login` endpoint. +3. Infisical validates the JWT signature using either: + - Pre-configured public keys (Static configuration) + - Public keys fetched from a JWKS endpoint (JWKS configuration) +4. Infisical verifies that the configured claims match in the token. This includes standard claims like subject, audience, and issuer, as well as any additional custom claims specified in the configuration. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + + For JWKS configuration, Infisical needs network-level access to the configured + JWKS endpoint. + + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the JWT authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + 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. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization 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. + + ![identities page](/images/platform/identities/identities-page.png) + + Since the identity has been configured with Universal Auth by default, you should re-configure it to use JWT Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new JWT Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png) + ![identities create jwt auth method](/images/platform/identities/identities-org-create-jwt-auth-method-static.png) + + Restrict access by properly configuring the JWT validation settings. + + Here's some more guidance for each field: + + **Static configuration**: + - Public Keys: One or more PEM-encoded public keys (RSA or ECDSA) used to verify JWT signatures. Each key must include the proper BEGIN/END markers. + + **JWKS configuration**: + - JWKS URL: The endpoint URL that serves your JSON Web Key Sets (JWKS). This endpoint must provide the public keys used for JWT signature verification. + - JWKS CA Certificate: Optional PEM-encoded CA certificate used for validating the TLS connection to the JWKS endpoint. + + **Common fields for both configurations**: + - Issuer: The unique identifier of the JWT provider. This value is used to verify the iss (issuer) claim in the JWT. + - Audiences: A list of intended recipients. This value is checked against the aud (audience) claim in the token. + - Subject: The expected principal that is the subject of the JWT. This value is checked against the sub (subject) claim in the token. + - Claims: Additional claims that must be present in the JWT for it to be valid. You can specify required claim names and their expected values. + - 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 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. + + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + To access the Infisical API as the identity, you will need to obtain a JWT from your JWT issuer that meets the validation requirements configured in step 2. + + Once you have obtained a valid JWT, you can use it to authenticate with Infisical at the `/api/v1/auth/jwt-auth/login` endpoint. + + We provide a code example below of how you might use the JWT to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction). + + + The shown example uses Node.js but you can use any other language to authenticate with Infisical using your JWT. + + ```javascript + try { + // Obtain JWT from your issuer + const jwt = ""; + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `{infisicalUrl}/api/v1/auth/jwt-auth/login`, + { + identityId, + jwt, + } + ); + + console.log("result data: ", data); // access token here + } catch(err) { + console.error(err); + } + ``` + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using JWT Auth as they handle the authentication process for you. + + + + 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 `2592000` seconds (30 days) which can be adjusted in the configuration. + + If an identity access token exceeds its max TTL or maximum number of uses, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation with a valid JWT. + + + + diff --git a/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png b/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png new file mode 100644 index 000000000..1f693b346 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-jwt-auth-method-jwks.png differ diff --git a/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png b/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png new file mode 100644 index 000000000..5d434a6e0 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-jwt-auth-method-static.png differ diff --git a/docs/mint.json b/docs/mint.json index 9c28137fa..030a0b4dc 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,10 +32,7 @@ "thumbsRating": true }, "api": { - "baseUrl": [ - "https://app.infisical.com", - "http://localhost:8080" - ] + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] }, "topbarLinks": [ { @@ -76,9 +73,7 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": [ - "documentation/guides/local-development" - ] + "pages": ["documentation/guides/local-development"] }, { "group": "Guides", @@ -229,6 +224,7 @@ "documentation/platform/identities/gcp-auth", "documentation/platform/identities/azure-auth", "documentation/platform/identities/aws-auth", + "documentation/platform/identities/jwt-auth", { "group": "OIDC Auth", "pages": [ @@ -467,15 +463,11 @@ }, { "group": "Build Tool Integrations", - "pages": [ - "integrations/build-tools/gradle" - ] + "pages": ["integrations/build-tools/gradle"] }, { "group": "", - "pages": [ - "sdks/overview" - ] + "pages": ["sdks/overview"] }, { "group": "SDK's", @@ -495,9 +487,7 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": [ - "api-reference/overview/examples/integration" - ] + "pages": ["api-reference/overview/examples/integration"] } ] }, @@ -593,6 +583,16 @@ "api-reference/endpoints/oidc-auth/revoke" ] }, + { + "group": "JWT Auth", + "pages": [ + "api-reference/endpoints/jwt-auth/login", + "api-reference/endpoints/jwt-auth/attach", + "api-reference/endpoints/jwt-auth/retrieve", + "api-reference/endpoints/jwt-auth/update", + "api-reference/endpoints/jwt-auth/revoke" + ] + }, { "group": "Groups", "pages": [ @@ -772,15 +772,11 @@ }, { "group": "Service Tokens", - "pages": [ - "api-reference/endpoints/service-tokens/get" - ] + "pages": ["api-reference/endpoints/service-tokens/get"] }, { "group": "Audit Logs", - "pages": [ - "api-reference/endpoints/audit-logs/export-audit-log" - ] + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] } ] }, @@ -879,9 +875,7 @@ }, { "group": "", - "pages": [ - "changelog/overview" - ] + "pages": ["changelog/overview"] }, { "group": "Contributing", @@ -905,9 +899,7 @@ }, { "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] + "pages": ["contributing/sdk/developing"] } ] } @@ -1090,4 +1082,4 @@ } ] } -} \ No newline at end of file +} diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 0c57ee82c..c11d7dc11 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -7,5 +7,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", - [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth" + [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", + [IdentityAuthMethod.JWT_AUTH]: "JWT Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 5e445521a..415492e00 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -5,5 +5,11 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-auth" + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth" +} + +export enum IdentityJwtConfigurationType { + JWKS = "jwks", + STATIC = "static" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 5c7bcc3e7..261556752 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -4,6 +4,7 @@ export { useAddIdentityAwsAuth, useAddIdentityAzureAuth, useAddIdentityGcpAuth, + useAddIdentityJwtAuth, useAddIdentityKubernetesAuth, useAddIdentityOidcAuth, useAddIdentityTokenAuth, @@ -15,6 +16,7 @@ export { useDeleteIdentityAwsAuth, useDeleteIdentityAzureAuth, useDeleteIdentityGcpAuth, + useDeleteIdentityJwtAuth, useDeleteIdentityKubernetesAuth, useDeleteIdentityOidcAuth, useDeleteIdentityTokenAuth, @@ -25,20 +27,24 @@ export { useUpdateIdentityAwsAuth, useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, + useUpdateIdentityJwtAuth, useUpdateIdentityKubernetesAuth, useUpdateIdentityOidcAuth, useUpdateIdentityTokenAuth, useUpdateIdentityTokenAuthToken, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityUniversalAuth +} from "./mutations"; export { useGetIdentityAwsAuth, useGetIdentityAzureAuth, useGetIdentityById, useGetIdentityGcpAuth, + useGetIdentityJwtAuth, useGetIdentityKubernetesAuth, useGetIdentityOidcAuth, useGetIdentityProjectMemberships, useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets} from "./queries"; + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 21c4c560e..8daaae236 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -8,6 +8,7 @@ import { AddIdentityAwsAuthDTO, AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, + AddIdentityJwtAuthDTO, AddIdentityKubernetesAuthDTO, AddIdentityOidcAuthDTO, AddIdentityTokenAuthDTO, @@ -22,6 +23,7 @@ import { DeleteIdentityAzureAuthDTO, DeleteIdentityDTO, DeleteIdentityGcpAuthDTO, + DeleteIdentityJwtAuthDTO, DeleteIdentityKubernetesAuthDTO, DeleteIdentityOidcAuthDTO, DeleteIdentityTokenAuthDTO, @@ -32,6 +34,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityOidcAuth, IdentityTokenAuth, @@ -42,6 +45,7 @@ import { UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, + UpdateIdentityJwtAuthDTO, UpdateIdentityKubernetesAuthDTO, UpdateIdentityOidcAuthDTO, UpdateIdentityTokenAuthDTO, @@ -518,6 +522,118 @@ export const useDeleteIdentityOidcAuth = () => { } }); }; +export const useUpdateIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.patch<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useAddIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.post<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useDeleteIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.delete(`/api/v1/auth/jwt-auth/identities/${identityId}`); + return identityJwtAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; export const useAddIdentityAzureAuth = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index c5c442407..49136614e 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -8,6 +8,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityMembership, IdentityMembershipOrg, @@ -29,6 +30,7 @@ export const identitiesKeys = { getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, + getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const, getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => @@ -276,3 +278,30 @@ export const useGetIdentityOidcAuth = ( enabled: Boolean(identityId) && (options?.enabled ?? true) }); }; + +export const useGetIdentityJwtAuth = ( + identityId: string, + options?: UseQueryOptions< + IdentityJwtAuth, + unknown, + IdentityJwtAuth, + ReturnType + > +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityJwtAuth(identityId), + queryFn: async () => { + const { + data: { identityJwtAuth } + } = await apiRequest.get<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}` + ); + + return identityJwtAuth; + }, + staleTime: 0, + cacheTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 559a01974..9100589d9 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,6 +1,6 @@ import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; -import { IdentityAuthMethod } from "./enums"; +import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; export type IdentityTrustedIp = { id: string; @@ -446,6 +446,65 @@ export type DeleteIdentityTokenAuthDTO = { identityId: string; }; +export type IdentityJwtAuth = { + identityId: string; + configurationType: IdentityJwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys?: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; +}; + export type CreateTokenIdentityTokenAuthDTO = { identityId: string; name: string; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 8852af872..fe03e5e68 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -23,12 +23,17 @@ import { useDeleteIdentityTokenAuth, useDeleteIdentityUniversalAuth } from "@app/hooks/api"; -import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities"; +import { + IdentityAuthMethod, + identityAuthToNameMap, + useDeleteIdentityJwtAuth +} from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; +import { IdentityJwtAuthForm } from "./IdentityJwtAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; @@ -68,7 +73,11 @@ const identityAuthMethods = [ { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH }, - { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH } + { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH }, + { + label: "JWT Auth", + value: IdentityAuthMethod.JWT_AUTH + } ]; const schema = yup @@ -100,6 +109,7 @@ export const IdentityAuthMethodModalContent = ({ const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth(); const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth(); const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth(); + const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth(); const { control, watch } = useForm({ resolver: yupResolver(schema), @@ -216,6 +226,17 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle={handlePopUpToggle} /> ) + }, + + [IdentityAuthMethod.JWT_AUTH]: { + revokeMethod: revokeJwtAuth, + render: () => ( + + ) } }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx new file mode 100644 index 000000000..d31bd43bd --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx @@ -0,0 +1,688 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +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, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useAddIdentityJwtAuth, useUpdateIdentityJwtAuth } from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums"; +import { useGetIdentityJwtAuth } from "@app/hooks/api/identities/queries"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const commonSchema = z.object({ + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .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(), + boundIssuer: z.string().trim().default(""), + boundAudiences: z.string().optional().default(""), + boundClaims: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + boundSubject: z.string().optional().default("") +}); + +const schema = z.discriminatedUnion("configurationType", [ + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.JWKS), + jwksUrl: z.string().trim().url(), + jwksCaCert: z.string().trim().default(""), + publicKeys: z + .object({ + value: z.string() + }) + .array() + .optional() + }) + .merge(commonSchema), + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.STATIC), + jwksUrl: z.string().trim().optional(), + jwksCaCert: z.string().trim().optional().default(""), + publicKeys: z + .object({ + value: z.string().min(1) + }) + .array() + .min(1) + }) + .merge(commonSchema) +]); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + configuredAuthMethods?: IdentityAuthMethod[]; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityJwtAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth(); + + const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes( + identityAuthMethodData.authMethod! || "" + ); + const { data } = useGetIdentityJwtAuth(identityAuthMethodData?.identityId ?? "", { + enabled: isUpdate + }); + + const { + watch, + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + configurationType: IdentityJwtConfigurationType.JWKS + } + }); + + const selectedConfigurationType = watch("configurationType") as IdentityJwtConfigurationType; + + const { + fields: publicKeyFields, + append: appendPublicKeyFields, + remove: removePublicKeyFields + } = useFieldArray({ + control, + name: "publicKeys" + }); + + const { + fields: boundClaimsFields, + append: appendBoundClaimField, + remove: removeBoundClaimField + } = useFieldArray({ + control, + name: "boundClaims" + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + configurationType: data.configurationType, + jwksUrl: data.jwksUrl, + jwksCaCert: data.jwksCaCert, + publicKeys: data.publicKeys.map((pk) => ({ + value: pk + })), + boundIssuer: data.boundIssuer, + boundAudiences: data.boundAudiences, + boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({ + key, + value + })), + boundSubject: data.boundSubject, + 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({ + configurationType: IdentityJwtConfigurationType.JWKS, + jwksUrl: "", + jwksCaCert: "", + boundIssuer: "", + boundAudiences: "", + boundClaims: [], + boundSubject: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + accessTokenTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }: FormData) => { + try { + if (!identityAuthMethodData) { + return; + } + + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + organizationId: orgId, + 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 (err) { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + {selectedConfigurationType === IdentityJwtConfigurationType.JWKS && ( + <> + ( + + + + )} + /> + ( + +