feat: finished crud endpoints

This commit is contained in:
Sheen Capadngan
2024-12-10 23:10:44 +08:00
parent 84c26581a6
commit c8ee06341a
7 changed files with 650 additions and 60 deletions

View File

@@ -14,13 +14,13 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("identityId").notNullable().unique(); t.uuid("identityId").notNullable().unique();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.string("configurationType").notNullable(); t.string("configurationType").notNullable();
t.string("jwksUrl"); t.string("jwksUrl").notNullable();
t.binary("encryptedJwksCaCert"); t.binary("encryptedJwksCaCert").notNullable();
t.binary("encryptedPublicKeys"); t.binary("encryptedPublicKeys").notNullable();
t.string("boundIssuer"); t.string("boundIssuer").notNullable();
t.string("boundAudiences"); t.string("boundAudiences").notNullable();
t.jsonb("boundClaims"); t.jsonb("boundClaims").notNullable();
t.string("boundSubject"); t.string("boundSubject").notNullable();
t.timestamps(true, true, true); t.timestamps(true, true, true);
}); });

View File

@@ -17,13 +17,13 @@ export const IdentityJwtAuthsSchema = z.object({
accessTokenTrustedIps: z.unknown(), accessTokenTrustedIps: z.unknown(),
identityId: z.string().uuid(), identityId: z.string().uuid(),
configurationType: z.string(), configurationType: z.string(),
jwksUrl: z.string().nullable().optional(), jwksUrl: z.string(),
encryptedJwksCaCert: zodBuffer.nullable().optional(), encryptedJwksCaCert: zodBuffer,
encryptedPublicKeys: zodBuffer.nullable().optional(), encryptedPublicKeys: zodBuffer,
boundIssuer: z.string().nullable().optional(), boundIssuer: z.string(),
boundAudiences: z.string().nullable().optional(), boundAudiences: z.string(),
boundClaims: z.unknown().nullable().optional(), boundClaims: z.unknown(),
boundSubject: z.string().nullable().optional(), boundSubject: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });

View File

@@ -94,6 +94,10 @@ export enum EventType {
UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth",
GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth",
REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-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", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-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", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
@@ -895,6 +899,58 @@ interface GetIdentityOidcAuthEvent {
}; };
} }
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<string, string>;
boundSubject: string;
accessTokenTTL: number;
accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
};
}
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<string, string>;
boundSubject?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
};
}
interface DeleteIdentityJwtAuthEvent {
type: EventType.REVOKE_IDENTITY_JWT_AUTH;
metadata: {
identityId: string;
};
}
interface GetIdentityJwtAuthEvent {
type: EventType.GET_IDENTITY_JWT_AUTH;
metadata: {
identityId: string;
};
}
interface CreateEnvironmentEvent { interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT; type: EventType.CREATE_ENVIRONMENT;
metadata: { metadata: {
@@ -1733,6 +1789,10 @@ export type Event =
| DeleteIdentityOidcAuthEvent | DeleteIdentityOidcAuthEvent
| UpdateIdentityOidcAuthEvent | UpdateIdentityOidcAuthEvent
| GetIdentityOidcAuthEvent | GetIdentityOidcAuthEvent
| AddIdentityJwtAuthEvent
| UpdateIdentityJwtAuthEvent
| GetIdentityJwtAuthEvent
| DeleteIdentityJwtAuthEvent
| CreateEnvironmentEvent | CreateEnvironmentEvent
| GetEnvironmentEvent | GetEnvironmentEvent
| UpdateEnvironmentEvent | UpdateEnvironmentEvent

View File

@@ -355,14 +355,13 @@ export const JWT_AUTH = {
}, },
ATTACH: { ATTACH: {
identityId: "The ID of the identity to attach the configuration onto.", identityId: "The ID of the identity to attach the configuration onto.",
caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.",
configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'",
jwksUrl: 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.", "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.", jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.",
publicKeys: 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.", "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 identity provider issuing the JWT.", boundIssuer: "The unique identifier of the JWT provider.",
boundAudiences: "The list of intended recipients.", boundAudiences: "The list of intended recipients.",
boundClaims: "The attributes that should be present in the JWT for it to be valid.", 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.", boundSubject: "The expected principal that is the subject of the JWT.",
@@ -370,6 +369,29 @@ export const JWT_AUTH = {
accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenTTL: "The lifetime for an access token in seconds.",
accessTokenMaxTTL: "The maximum 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." 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; } as const;

View File

@@ -1,10 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { IdentityJwtAuthsSchema } from "@app/db/schemas"; 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 { JWT_AUTH } from "@app/lib/api-docs";
import { writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; 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 { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types";
import { import {
validateJwtAuthAudiencesField, validateJwtAuthAudiencesField,
@@ -16,7 +18,7 @@ const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({
encryptedPublicKeys: true encryptedPublicKeys: true
}).extend({ }).extend({
jwksCaCert: z.string(), jwksCaCert: z.string(),
publicKeys: z.string() publicKeys: z.string().array()
}); });
export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => {
@@ -37,43 +39,246 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider)
params: z.object({ params: z.object({
identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId) identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId)
}), }),
body: z.object({ body: z
configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.ATTACH.configurationType), .object({
jwksUrl: z.string().describe(JWT_AUTH.ATTACH.jwksUrl), configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.ATTACH.configurationType),
jwksCaCert: z.string().describe(JWT_AUTH.ATTACH.jwksCaCert), jwksUrl: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksUrl),
publicKeys: z.string().array().describe(JWT_AUTH.ATTACH.publicKeys), jwksCaCert: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksCaCert),
boundIssuer: z.string().min(1).describe(JWT_AUTH.ATTACH.boundIssuer), publicKeys: z.string().min(1).array().describe(JWT_AUTH.ATTACH.publicKeys),
boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), boundIssuer: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundIssuer),
boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences),
boundSubject: z.string().optional().default("").describe(JWT_AUTH.ATTACH.boundSubject), boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims),
accessTokenTrustedIps: z boundSubject: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundSubject),
.object({ accessTokenTrustedIps: z
ipAddress: z.string().trim() .object({
}) ipAddress: z.string().trim()
.array() })
.min(1) .array()
.default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) .min(1)
.describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }])
accessTokenTTL: z .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps),
.number() accessTokenTTL: z
.int() .number()
.min(1) .int()
.max(315360000) .min(1)
.refine((value) => value !== 0, { .max(315360000)
message: "accessTokenTTL must have a non zero number" .refine((value) => value !== 0, {
}) message: "accessTokenTTL must have a non zero number"
.default(2592000) })
.describe(JWT_AUTH.ATTACH.accessTokenTTL), .default(2592000)
accessTokenMaxTTL: z .describe(JWT_AUTH.ATTACH.accessTokenTTL),
.number() accessTokenMaxTTL: z
.int() .number()
.max(315360000) .int()
.refine((value) => value !== 0, { .max(315360000)
message: "accessTokenMaxTTL must have a non zero number" .refine((value) => value !== 0, {
}) message: "accessTokenMaxTTL must have a non zero number"
.default(2592000) })
.describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), .default(2592000)
accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL),
accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit)
})
.superRefine((data, ctx) => {
if (data.configurationType === JwtConfigurationType.JWKS) {
if (!data.jwksUrl) {
ctx.addIssue({
path: ["jwksUrl"],
message: "JWKS url is required",
code: z.ZodIssueCode.custom
});
}
} else if (data.configurationType === JwtConfigurationType.STATIC) {
if (data.publicKeys.length === 0) {
ctx.addIssue({
path: ["publicKeys"],
message: "public key is required",
code: z.ZodIssueCode.custom
});
}
}
}),
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<string, string>,
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
.object({
configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.UPDATE.configurationType),
jwksUrl: z.string().trim().describe(JWT_AUTH.UPDATE.jwksUrl),
jwksCaCert: z.string().trim().describe(JWT_AUTH.UPDATE.jwksCaCert),
publicKeys: z.string().array().describe(JWT_AUTH.UPDATE.publicKeys),
boundIssuer: z.string().trim().describe(JWT_AUTH.UPDATE.boundIssuer),
boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.UPDATE.boundAudiences),
boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.UPDATE.boundClaims),
boundSubject: z.string().trim().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()
.superRefine((data, ctx) => {
if (data.configurationType === JwtConfigurationType.JWKS) {
if (!data.jwksUrl) {
ctx.addIssue({
path: ["jwksUrl"],
message: "JWKS url is required",
code: z.ZodIssueCode.custom
});
}
} else if (data.configurationType === JwtConfigurationType.STATIC) {
if (data.publicKeys?.length === 0) {
ctx.addIssue({
path: ["publicKeys"],
message: "public key is required",
code: z.ZodIssueCode.custom
});
}
}
}),
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<string, string>,
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: { response: {
200: z.object({ 200: z.object({
@@ -81,6 +286,77 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider)
}) })
} }
}, },
handler: async (req) => {} 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 };
}
}); });
}; };

View File

@@ -1,18 +1,20 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { IdentityAuthMethod } from "@app/db/schemas"; import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal";
import { TAttachJwtAuthDTO } from "./identity-jwt-auth-types"; import { TAttachJwtAuthDTO, TGetJwtAuthDTO, TRevokeJwtAuthDTO, TUpdateJwtAuthDTO } from "./identity-jwt-auth-types";
type TIdentityJwtAuthServiceFactoryDep = { type TIdentityJwtAuthServiceFactoryDep = {
identityJwtAuthDAL: TIdentityJwtAuthDALFactory; identityJwtAuthDAL: TIdentityJwtAuthDALFactory;
@@ -30,6 +32,7 @@ export const identityJwtAuthServiceFactory = ({
identityOrgMembershipDAL, identityOrgMembershipDAL,
permissionService, permissionService,
licenseService, licenseService,
identityAccessTokenDAL,
kmsService kmsService
}: TIdentityJwtAuthServiceFactoryDep) => { }: TIdentityJwtAuthServiceFactoryDep) => {
const attachJwtAuth = async ({ const attachJwtAuth = async ({
@@ -131,7 +134,212 @@ export const identityJwtAuthServiceFactory = ({
return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; 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) {
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 { return {
attachJwtAuth attachJwtAuth,
updateJwtAuth,
getJwtAuth,
revokeJwtAuth
}; };
}; };

View File

@@ -20,3 +20,27 @@ export type TAttachJwtAuthDTO = {
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateJwtAuthDTO = {
identityId: string;
configurationType?: JwtConfigurationType;
jwksUrl?: string;
jwksCaCert?: string;
publicKeys?: string[];
boundIssuer?: string;
boundAudiences?: string;
boundClaims?: Record<string, string>;
boundSubject?: string;
accessTokenTTL?: number;
accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[];
} & Omit<TProjectPermission, "projectId">;
export type TGetJwtAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRevokeJwtAuthDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;