Merge pull request #4770 from Infisical/misc/add-infisical-specific-otel-metrics

misc: add custom metrics
This commit is contained in:
Sheen
2025-10-31 23:21:01 +08:00
committed by GitHub
29 changed files with 2255 additions and 1384 deletions
+14
View File
@@ -135,9 +135,23 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ
declare module "@fastify/request-context" { declare module "@fastify/request-context" {
interface RequestContextData { interface RequestContextData {
reqId: string; reqId: string;
ip?: string;
userAgent?: string;
orgId?: string; orgId?: string;
orgName?: string;
userAuthInfo?: {
userId: string;
email: string;
};
projectDetails?: {
id: string;
name: string;
slug: string;
};
identityAuthInfo?: { identityAuthInfo?: {
identityId: string; identityId: string;
identityName: string;
authMethod: string;
oidc?: { oidc?: {
claims: Record<string, string>; claims: Record<string, string>;
}; };
+35 -9
View File
@@ -7,6 +7,7 @@
// All the any rules are disabled because passport typesense with fastify is really poor // All the any rules are disabled because passport typesense with fastify is really poor
import { Authenticator } from "@fastify/passport"; import { Authenticator } from "@fastify/passport";
import { requestContext } from "@fastify/request-context";
import fastifySession from "@fastify/session"; import fastifySession from "@fastify/session";
import { MultiSamlStrategy } from "@node-saml/passport-saml"; import { MultiSamlStrategy } from "@node-saml/passport-saml";
import { FastifyRequest } from "fastify"; import { FastifyRequest } from "fastify";
@@ -17,6 +18,7 @@ import { ApiDocsTags, SamlSso } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { readLimit, 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 { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config"; import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config";
@@ -102,15 +104,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req, profile, cb) => { async (req, profile, cb) => {
if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email =
profile?.email ??
// entra sends data in this format
(profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ??
(profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\
try { try {
if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email =
profile?.email ??
// entra sends data in this format
(profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ??
(profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\
const firstName = (profile.firstName ?? const firstName = (profile.firstName ??
// entra sends data in this format // entra sends data in this format
profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string; profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string;
@@ -144,7 +146,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}) })
.filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key));
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ const { isUserCompleted, providerAuthToken, user, organization } = await server.services.saml.samlLogin({
externalId: profile.nameID, externalId: profile.nameID,
email: email.toLowerCase(), email: email.toLowerCase(),
firstName, firstName,
@@ -154,8 +156,32 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId,
metadata: userMetadata metadata: userMetadata
}); });
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email.toLowerCase(),
"infisical.user.id": user.id,
"infisical.organization.id": organization.id,
"infisical.organization.name": organization.name,
"infisical.auth.method": AuthAttemptAuthMethod.SAML,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email.toLowerCase(),
"infisical.auth.method": AuthAttemptAuthMethod.SAML,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
logger.error(error); logger.error(error);
cb(error as Error); cb(error as Error);
} }
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client";
import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas";
@@ -15,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto"; import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
@@ -471,7 +473,7 @@ export const oidcConfigServiceFactory = ({
}); });
} }
return { isUserCompleted, providerAuthToken }; return { isUserCompleted, providerAuthToken, user };
}; };
const updateOidcCfg = async ({ const updateOidcCfg = async ({
@@ -754,10 +756,35 @@ export const oidcConfigServiceFactory = ({
callbackPort, callbackPort,
manageGroupMemberships: oidcCfg.manageGroupMemberships manageGroupMemberships: oidcCfg.manageGroupMemberships
}) })
.then(({ isUserCompleted, providerAuthToken }) => { .then(({ isUserCompleted, providerAuthToken, user }) => {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": claims?.email?.toLowerCase(),
"infisical.user.id": user.id,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
}) })
.catch((error) => { .catch((error) => {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": claims?.email?.toLowerCase(),
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
cb(error); cb(error);
}); });
} }
@@ -337,6 +337,12 @@ export const permissionServiceFactory = ({
throw new NotFoundError({ message: `Project with ${projectId} not found` }); throw new NotFoundError({ message: `Project with ${projectId} not found` });
} }
requestContext.set("projectDetails", {
id: projectDetails.id,
name: projectDetails.name,
slug: projectDetails.slug
});
if (projectDetails.orgId !== actorOrgId) { if (projectDetails.orgId !== actorOrgId) {
throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
} }
@@ -769,7 +769,7 @@ export const samlConfigServiceFactory = ({
}); });
} }
return { isUserCompleted, providerAuthToken }; return { isUserCompleted, providerAuthToken, user, organization };
}; };
return { return {
@@ -1,4 +1,4 @@
import { TSamlConfigs } from "@app/db/schemas"; import { TOrganizations, TSamlConfigs, TUsers } from "@app/db/schemas";
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
@@ -78,5 +78,7 @@ export type TSamlConfigServiceFactory = {
samlLogin: (arg: TSamlLoginDTO) => Promise<{ samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean; isUserCompleted: boolean;
providerAuthToken: string; providerAuthToken: string;
user: TUsers;
organization: TOrganizations;
}>; }>;
}; };
+100
View File
@@ -0,0 +1,100 @@
import { requestContext } from "@fastify/request-context";
import opentelemetry from "@opentelemetry/api";
import { getConfig } from "../config/env";
const infisicalMeter = opentelemetry.metrics.getMeter("Infisical");
export enum AuthAttemptAuthMethod {
EMAIL = "email",
SAML = "saml",
OIDC = "oidc",
GOOGLE = "google",
GITHUB = "github",
GITLAB = "gitlab",
TOKEN_AUTH = "token-auth",
UNIVERSAL_AUTH = "universal-auth",
KUBERNETES_AUTH = "kubernetes-auth",
GCP_AUTH = "gcp-auth",
ALICLOUD_AUTH = "alicloud-auth",
AWS_AUTH = "aws-auth",
AZURE_AUTH = "azure-auth",
TLS_CERT_AUTH = "tls-cert-auth",
OCI_AUTH = "oci-auth",
OIDC_AUTH = "oidc-auth",
JWT_AUTH = "jwt-auth",
LDAP_AUTH = "ldap-auth"
}
export enum AuthAttemptAuthResult {
SUCCESS = "success",
FAILURE = "failure"
}
export const authAttemptCounter = infisicalMeter.createCounter("infisical.auth.attempt.count", {
description: "Authentication attempts (both successful and failed)",
unit: "{attempt}"
});
export const secretReadCounter = infisicalMeter.createCounter("infisical.secret.read.count", {
description: "Number of secret read operations",
unit: "{operation}"
});
export const recordSecretReadMetric = (params: { environment: string; secretPath: string; name?: string }) => {
const appCfg = getConfig();
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const attributes: Record<string, string> = {
"infisical.environment": params.environment,
"infisical.secret.path": params.secretPath,
...(params.name ? { "infisical.secret.name": params.name } : {})
};
const orgId = requestContext.get("orgId");
if (orgId) {
attributes["infisical.organization.id"] = orgId;
}
const orgName = requestContext.get("orgName");
if (orgName) {
attributes["infisical.organization.name"] = orgName;
}
const projectDetails = requestContext.get("projectDetails");
if (projectDetails?.id) {
attributes["infisical.project.id"] = projectDetails.id;
}
if (projectDetails?.name) {
attributes["infisical.project.name"] = projectDetails.name;
}
const userAuthInfo = requestContext.get("userAuthInfo");
if (userAuthInfo?.userId) {
attributes["infisical.user.id"] = userAuthInfo.userId;
}
if (userAuthInfo?.email) {
attributes["infisical.user.email"] = userAuthInfo.email;
}
const identityAuthInfo = requestContext.get("identityAuthInfo");
if (identityAuthInfo?.identityId) {
attributes["infisical.identity.id"] = identityAuthInfo.identityId;
}
if (identityAuthInfo?.identityName) {
attributes["infisical.identity.name"] = identityAuthInfo.identityName;
}
const userAgent = requestContext.get("userAgent");
if (userAgent) {
attributes["user_agent.original"] = userAgent;
}
const ip = requestContext.get("ip");
if (ip) {
attributes["client.address"] = ip;
}
secretReadCounter.add(1, attributes);
}
};
+3 -1
View File
@@ -141,7 +141,9 @@ export const main = async ({
await server.register(fastifyRequestContext, { await server.register(fastifyRequestContext, {
defaultStoreValues: (req) => ({ defaultStoreValues: (req) => ({
reqId: req.id, reqId: req.id,
log: req.log.child({ reqId: req.id }) log: req.log.child({ reqId: req.id }),
ip: req.realIp,
userAgent: req.headers["user-agent"]
}) })
}); });
+81 -5
View File
@@ -1,12 +1,26 @@
import { requestContext } from "@fastify/request-context";
import opentelemetry from "@opentelemetry/api"; import opentelemetry from "@opentelemetry/api";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
export const apiMetrics = fp(async (fastify) => { const apiMeter = opentelemetry.metrics.getMeter("API");
const apiMeter = opentelemetry.metrics.getMeter("API");
const latencyHistogram = apiMeter.createHistogram("API_latency", {
unit: "ms"
});
const latencyHistogram = apiMeter.createHistogram("API_latency", {
unit: "ms"
});
const infisicalMeter = opentelemetry.metrics.getMeter("Infisical");
const requestCounter = infisicalMeter.createCounter("infisical.http.server.request.count", {
description: "Total number of API requests to Infisical (covers both human users and machine identities)",
unit: "{request}"
});
const requestDurationHistogram = infisicalMeter.createHistogram("infisical.http.server.request.duration", {
description: "API request latency",
unit: "s"
});
export const apiMetrics = fp(async (fastify) => {
fastify.addHook("onResponse", async (request, reply) => { fastify.addHook("onResponse", async (request, reply) => {
const { method } = request; const { method } = request;
const route = request.routerPath; const route = request.routerPath;
@@ -17,5 +31,67 @@ export const apiMetrics = fp(async (fastify) => {
method, method,
statusCode statusCode
}); });
const orgId = requestContext.get("orgId");
const orgName = requestContext.get("orgName");
const userAuthInfo = requestContext.get("userAuthInfo");
const identityAuthInfo = requestContext.get("identityAuthInfo");
const projectDetails = requestContext.get("projectDetails");
const userAgent = requestContext.get("userAgent");
const ip = requestContext.get("ip");
const attributes: Record<string, string | number> = {
"http.request.method": method,
"http.route": route,
"http.response.status_code": statusCode
};
if (orgId) {
attributes["infisical.organization.id"] = orgId;
}
if (orgName) {
attributes["infisical.organization.name"] = orgName;
}
if (userAuthInfo) {
if (userAuthInfo.userId) {
attributes["infisical.user.id"] = userAuthInfo.userId;
}
if (userAuthInfo.email) {
attributes["infisical.user.email"] = userAuthInfo.email;
}
}
if (identityAuthInfo) {
if (identityAuthInfo.identityId) {
attributes["infisical.identity.id"] = identityAuthInfo.identityId;
}
if (identityAuthInfo.identityName) {
attributes["infisical.identity.name"] = identityAuthInfo.identityName;
}
if (identityAuthInfo.authMethod) {
attributes["infisical.auth.method"] = identityAuthInfo.authMethod;
}
}
if (projectDetails) {
if (projectDetails.id) {
attributes["infisical.project.id"] = projectDetails.id;
}
if (projectDetails.name) {
attributes["infisical.project.name"] = projectDetails.name;
}
}
if (userAgent) {
attributes["user_agent.original"] = userAgent;
}
if (ip) {
attributes["client.address"] = ip;
}
requestCounter.add(1, attributes);
requestDurationHistogram.record(reply.elapsedTime / 1000, attributes);
}); });
}); });
@@ -1,4 +1,4 @@
import { requestContext } from "@fastify/request-context"; import { requestContext, RequestContextData } from "@fastify/request-context";
import { FastifyRequest } from "fastify"; import { FastifyRequest } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import type { JwtPayload } from "jsonwebtoken"; import type { JwtPayload } from "jsonwebtoken";
@@ -159,10 +159,11 @@ export const injectIdentity = fp(
switch (authMode) { switch (authMode) {
case AuthMode.JWT: { case AuthMode.JWT: {
const { user, tokenVersionId, orgId, rootOrgId, parentOrgId } = const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } =
await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector);
requestContext.set("orgId", orgId); requestContext.set("orgId", orgId);
requestContext.set("orgName", orgName);
requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" });
req.auth = { req.auth = {
authMode: AuthMode.JWT, authMode: AuthMode.JWT,
user, user,
@@ -186,6 +187,7 @@ export const injectIdentity = fp(
); );
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
requestContext.set("orgId", identity.orgId); requestContext.set("orgId", identity.orgId);
requestContext.set("orgName", identity.orgName);
req.auth = { req.auth = {
authMode: AuthMode.IDENTITY_ACCESS_TOKEN, authMode: AuthMode.IDENTITY_ACCESS_TOKEN,
actor, actor,
@@ -198,24 +200,23 @@ export const injectIdentity = fp(
isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId),
token token
}; };
const identityAuthInfo: RequestContextData["identityAuthInfo"] = {
identityId: identity.identityId,
identityName: identity.name,
authMethod: identity.authMethod
};
if (token?.identityAuth?.oidc) { if (token?.identityAuth?.oidc) {
requestContext.set("identityAuthInfo", { identityAuthInfo.oidc = token?.identityAuth?.oidc;
identityId: identity.identityId,
oidc: token?.identityAuth?.oidc
});
} }
if (token?.identityAuth?.kubernetes) { if (token?.identityAuth?.kubernetes) {
requestContext.set("identityAuthInfo", { identityAuthInfo.kubernetes = token?.identityAuth?.kubernetes;
identityId: identity.identityId,
kubernetes: token?.identityAuth?.kubernetes
});
} }
if (token?.identityAuth?.aws) { if (token?.identityAuth?.aws) {
requestContext.set("identityAuthInfo", { identityAuthInfo.aws = token?.identityAuth?.aws;
identityId: identity.identityId,
aws: token?.identityAuth?.aws
});
} }
requestContext.set("identityAuthInfo", identityAuthInfo);
break; break;
} }
case AuthMode.SERVICE_TOKEN: { case AuthMode.SERVICE_TOKEN: {
@@ -1,4 +1,5 @@
import { ForbiddenError, PureAbility } from "@casl/ability"; import { ForbiddenError, PureAbility } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import opentelemetry from "@opentelemetry/api"; import opentelemetry from "@opentelemetry/api";
import fastifyPlugin from "fastify-plugin"; import fastifyPlugin from "fastify-plugin";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
@@ -47,6 +48,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
unit: "1" unit: "1"
}); });
const infisicalMeter = opentelemetry.metrics.getMeter("Infisical");
const errorCounter = infisicalMeter.createCounter("infisical.http.server.error.count", {
description: "Total number of API errors in Infisical (covers both human users and machine identities)",
unit: "{error}"
});
server.setErrorHandler((error, req, res) => { server.setErrorHandler((error, req, res) => {
req.log.error(error); req.log.error(error);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
@@ -61,6 +68,67 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
type: errorType, type: errorType,
name: error.name name: error.name
}); });
const orgId = requestContext.get("orgId");
const orgName = requestContext.get("orgName");
const userAuthInfo = requestContext.get("userAuthInfo");
const identityAuthInfo = requestContext.get("identityAuthInfo");
const projectDetails = requestContext.get("projectDetails");
const attributes: Record<string, string | number> = {
"http.request.method": method,
"http.route": route,
"error.type": errorType,
"error.name": error.name
};
if (orgId) {
attributes["infisical.organization.id"] = orgId;
}
if (orgName) {
attributes["infisical.organization.name"] = orgName;
}
if (userAuthInfo) {
if (userAuthInfo.userId) {
attributes["infisical.user.id"] = userAuthInfo.userId;
}
if (userAuthInfo.email) {
attributes["infisical.user.email"] = userAuthInfo.email;
}
}
if (identityAuthInfo) {
if (identityAuthInfo.identityId) {
attributes["infisical.identity.id"] = identityAuthInfo.identityId;
}
if (identityAuthInfo.identityName) {
attributes["infisical.identity.name"] = identityAuthInfo.identityName;
}
if (identityAuthInfo.authMethod) {
attributes["infisical.auth.method"] = identityAuthInfo.authMethod;
}
}
if (projectDetails) {
if (projectDetails.id) {
attributes["infisical.project.id"] = projectDetails.id;
}
if (projectDetails.name) {
attributes["infisical.project.name"] = projectDetails.name;
}
}
const userAgent = req.headers["user-agent"];
if (userAgent) {
attributes["user_agent.original"] = userAgent;
}
if (req.realIp) {
attributes["client.address"] = req.realIp;
}
errorCounter.add(1, attributes);
} }
if (error instanceof BadRequestError) { if (error instanceof BadRequestError) {
+2 -1
View File
@@ -1705,7 +1705,8 @@ export const registerRoutes = async (
licenseService, licenseService,
permissionService, permissionService,
kmsService, kmsService,
membershipIdentityDAL membershipIdentityDAL,
orgDAL
}); });
const identityAwsAuthService = identityAwsAuthServiceFactory({ const identityAwsAuthService = identityAwsAuthServiceFactory({
+113 -39
View File
@@ -7,6 +7,7 @@
// All the any rules are disabled because passport typesense with fastify is really poor // All the any rules are disabled because passport typesense with fastify is really poor
import { Authenticator } from "@fastify/passport"; import { Authenticator } from "@fastify/passport";
import { requestContext } from "@fastify/request-context";
import fastifySession from "@fastify/session"; import fastifySession from "@fastify/session";
import RedisStore from "connect-redis"; import RedisStore from "connect-redis";
import { CronJob } from "cron"; import { CronJob } from "cron";
@@ -21,6 +22,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
import { AuthMethod } from "@app/services/auth/auth-type"; import { AuthMethod } from "@app/services/auth/auth-type";
@@ -51,30 +53,54 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req, _accessToken, _refreshToken, profile, cb) => { async (req, _accessToken, _refreshToken, profile, cb) => {
try { // @ts-expect-error this is because this is express type and not fastify
// @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort");
const callbackPort = req.session.get("callbackPort"); // @ts-expect-error this is because this is express type and not fastify
// @ts-expect-error this is because this is express type and not fastify const orgSlug = req.session.get("orgSlug");
const orgSlug = req.session.get("orgSlug");
const email = profile?.emails?.[0]?.value; const email = profile?.emails?.[0]?.value;
if (!email) if (!email)
throw new NotFoundError({ throw new NotFoundError({
message: "Email not found", message: "Email not found",
name: "OauthGoogleRegister" name: "OauthGoogleRegister"
});
try {
const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
await server.services.login.oauth2Login({
email,
firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE,
callbackPort,
orgSlug
}); });
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
email, authAttemptCounter.add(1, {
firstName: profile?.name?.givenName || "", "infisical.user.email": email,
lastName: profile?.name?.familyName || "", "infisical.user.id": user.id,
authMethod: AuthMethod.GOOGLE, "infisical.organization.id": orgId,
callbackPort, "infisical.organization.name": orgName,
orgSlug "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
}); "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
cb(error as Error, false); cb(error as Error, false);
} }
} }
@@ -101,27 +127,50 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => {
const ghEmails = await fetchGithubEmails(accessToken);
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
if (!email) throw new Error("No primary email found");
try { try {
const ghEmails = await fetchGithubEmails(accessToken);
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
if (!email) throw new Error("No primary email found");
// profile does not get automatically populated so we need to manually fetch user info // profile does not get automatically populated so we need to manually fetch user info
const user = await fetchGithubUser(accessToken); const githubUser = await fetchGithubUser(accessToken);
const callbackPort = req.session.get("callbackPort"); const callbackPort = req.session.get("callbackPort");
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
email, await server.services.login.oauth2Login({
firstName: user.name || user.login, email,
lastName: "", firstName: githubUser.name || githubUser.login,
authMethod: AuthMethod.GITHUB, lastName: "",
callbackPort authMethod: AuthMethod.GITHUB,
}); callbackPort
});
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.user.id": user.id,
"infisical.organization.id": orgId,
"infisical.organization.name": orgName,
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken });
} catch (err) { } catch (err) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
logger.error(err); logger.error(err);
done(err as Error, false); done(err as Error, false);
} }
@@ -147,20 +196,45 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
pkce: true pkce: true
}, },
async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => {
const email = profile.emails[0].value;
try { try {
const callbackPort = req.session.get("callbackPort"); const callbackPort = req.session.get("callbackPort");
const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ await server.services.login.oauth2Login({
email, email,
firstName: profile.displayName || profile.username || "", firstName: profile.displayName || profile.username || "",
lastName: "", lastName: "",
authMethod: AuthMethod.GITLAB, authMethod: AuthMethod.GITLAB,
callbackPort callbackPort
}); });
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.user.id": user.id,
"infisical.organization.id": orgId,
"infisical.organization.name": orgName,
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
return cb(null, { isUserCompleted, providerAuthToken }); return cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
logger.error(error); logger.error(error);
cb(error as Error, false); cb(error as Error, false);
} }
@@ -210,6 +210,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD
if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` });
let orgId = ""; let orgId = "";
let orgName = "";
let rootOrgId = ""; let rootOrgId = "";
let parentOrgId = ""; let parentOrgId = "";
if (token.organizationId) { if (token.organizationId) {
@@ -235,9 +236,11 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD
throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); throw new ForbiddenRequestError({ message: "User organization membership is inactive" });
} }
orgId = subOrganization.id; orgId = subOrganization.id;
orgName = subOrganization.name;
rootOrgId = token.organizationId; rootOrgId = token.organizationId;
parentOrgId = subOrganization.parentOrgId as string; parentOrgId = subOrganization.parentOrgId as string;
} else { } else {
const organization = await orgDAL.findOne({ id: token.organizationId });
const orgMembership = await membershipUserDAL.findOne({ const orgMembership = await membershipUserDAL.findOne({
actorUserId: user.id, actorUserId: user.id,
scopeOrgId: token.organizationId, scopeOrgId: token.organizationId,
@@ -253,12 +256,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD
} }
orgId = token.organizationId; orgId = token.organizationId;
orgName = organization.name;
rootOrgId = token.organizationId; rootOrgId = token.organizationId;
parentOrgId = token.organizationId; parentOrgId = token.organizationId;
} }
} }
return { user, tokenVersionId: token.tokenVersionId, orgId, rootOrgId, parentOrgId }; return { user, tokenVersionId: token.tokenVersionId, orgId, orgName, rootOrgId, parentOrgId };
}; };
return { return {
+79 -46
View File
@@ -16,6 +16,7 @@ import { getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getUserAgentType } from "@app/server/plugins/audit-log";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
@@ -385,63 +386,94 @@ export const authLoginServiceFactory = ({
providerAuthToken?: string; providerAuthToken?: string;
captchaToken?: string; captchaToken?: string;
}) => { }) => {
const usersByUsername = await userDAL.findUserEncKeyByUsername({ const appCfg = getConfig();
username: email
});
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!userEnc) throw new BadRequestError({ message: "User not found" }); try {
const usersByUsername = await userDAL.findUserEncKeyByUsername({
username: email
});
const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (userEnc.encryptionVersion !== UserEncryption.V2) { if (!userEnc) throw new BadRequestError({ message: "User not found" });
throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" });
}
if (!userEnc.hashedPassword) { if (userEnc.encryptionVersion !== UserEncryption.V2) {
if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
throw new BadRequestError({ throw new BadRequestError({
message: "Legacy encryption scheme not supported", message: "Legacy encryption scheme not supported",
name: "LegacyEncryptionScheme" name: "LegacyEncryptionScheme"
}); });
} }
throw new BadRequestError({ message: "No password found" }); if (!userEnc.hashedPassword) {
} if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
throw new BadRequestError({
const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); message: "Legacy encryption scheme not supported",
await verifyCaptcha(userEnc, captchaToken); name: "LegacyEncryptionScheme"
});
if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) {
await userDAL.update(
{ id: userEnc.userId },
{
$incr: {
consecutiveFailedPasswordAttempts: 1
}
} }
);
throw new BadRequestError({ message: "Invalid username or email" }); throw new BadRequestError({ message: "No password found" });
}
const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken);
await verifyCaptcha(userEnc, captchaToken);
if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) {
await userDAL.update(
{ id: userEnc.userId },
{
$incr: {
consecutiveFailedPasswordAttempts: 1
}
}
);
throw new BadRequestError({ message: "Invalid username or email" });
}
const token = await generateUserTokens({
user: {
...userEnc,
id: userEnc.userId
},
ip,
userAgent,
authMethod,
organizationId
});
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.organization.id": organizationId,
"infisical.user.email": email,
"infisical.user.id": userEnc.userId,
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": ip,
"user_agent.original": userAgent
});
}
return {
tokens: {
accessToken: token.access,
refreshToken: token.refresh
},
user: userEnc
} as const;
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": ip,
"user_agent.original": userAgent
});
}
throw error;
} }
const token = await generateUserTokens({
user: {
...userEnc,
id: userEnc.userId
},
ip,
userAgent,
authMethod,
organizationId
});
return {
tokens: {
accessToken: token.access,
refreshToken: token.refresh
},
user: userEnc
} as const;
}; };
const selectOrganization = async ({ const selectOrganization = async ({
@@ -965,7 +997,8 @@ export const authLoginServiceFactory = ({
expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME
} }
); );
return { isUserCompleted, providerAuthToken };
return { isUserCompleted, providerAuthToken, user, orgId, orgName };
}; };
/** /**
@@ -210,6 +210,7 @@ export const identityAccessTokenServiceFactory = ({
}); });
} }
let orgId = ""; let orgId = "";
let orgName = "";
let parentOrgId = ""; let parentOrgId = "";
const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId });
const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id;
@@ -229,8 +230,12 @@ export const identityAccessTokenServiceFactory = ({
throw new BadRequestError({ message: "Identity does not belong to any organization" }); throw new BadRequestError({ message: "Identity does not belong to any organization" });
} }
orgId = subOrganization.id; orgId = subOrganization.id;
orgName = subOrganization.name;
parentOrgId = subOrganization.parentOrgId as string; parentOrgId = subOrganization.parentOrgId as string;
} else { } else {
const organization = await orgDAL.findOne({ id: rootOrgId });
const identityOrgMembership = await membershipIdentityDAL.findOne({ const identityOrgMembership = await membershipIdentityDAL.findOne({
scope: AccessScope.Organization, scope: AccessScope.Organization,
actorIdentityId: identityAccessToken.identityId, actorIdentityId: identityAccessToken.identityId,
@@ -242,6 +247,7 @@ export const identityAccessTokenServiceFactory = ({
} }
orgId = rootOrgId; orgId = rootOrgId;
orgName = organization.name;
parentOrgId = rootOrgId; parentOrgId = rootOrgId;
} }
@@ -253,7 +259,7 @@ export const identityAccessTokenServiceFactory = ({
await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses });
await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1);
return { ...identityAccessToken, orgId, rootOrgId, parentOrgId }; return { ...identityAccessToken, orgId, rootOrgId, parentOrgId, orgName };
}; };
return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken };
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
@@ -22,6 +23,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -65,6 +67,7 @@ export const identityAliCloudAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAliCloudAuthServiceFactoryDep) => { }: TIdentityAliCloudAuthServiceFactoryDep) => {
const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => {
const appCfg = getConfig();
const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId });
if (!identityAliCloudAuth) { if (!identityAliCloudAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -75,73 +78,103 @@ export const identityAliCloudAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAliCloudAuth.identityId); const identity = await identityDAL.findById(identityAliCloudAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const requestUrl = new URL("https://sts.aliyuncs.com"); const org = await orgDAL.findById(identity.orgId);
for (const key of Object.keys(params)) { try {
requestUrl.searchParams.set(key, (params as Record<string, string>)[key]); const requestUrl = new URL("https://sts.aliyuncs.com");
}
const { data } = await request.get<TAliCloudGetUserResponse>(requestUrl.toString()).catch((err: AxiosError) => { for (const key of Object.keys(params)) {
logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); requestUrl.searchParams.set(key, (params as Record<string, string>)[key]);
throw err; }
});
if (identityAliCloudAuth.allowedArns) { const { data } = await request.get<TAliCloudGetUserResponse>(requestUrl.toString()).catch((err: AxiosError) => {
// In the future we could do partial checks for role ARNs logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud");
const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); throw err;
});
if (!isAccountAllowed) if (identityAliCloudAuth.allowedArns) {
throw new UnauthorizedError({ // In the future we could do partial checks for role ARNs
message: "Access denied: Alibaba Cloud account ARN not allowed." const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn);
});
}
// Generate the token if (!isAccountAllowed)
const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { throw new UnauthorizedError({
await membershipIdentityDAL.update( message: "Access denied: Alibaba Cloud account ARN not allowed."
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, });
{ }
lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH,
lastLoginTime: new Date() // Generate the token
}, const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => {
tx await membershipIdentityDAL.update(
); { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
const newToken = await identityAccessTokenDAL.create( {
lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAliCloudAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAliCloudAuth.accessTokenTTL,
accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.ALICLOUD_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityAliCloudAuth.identityId, identityId: identityAliCloudAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAliCloudAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.ALICLOUD_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityAliCloudAuth.identityId,
identityId: identityAliCloudAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityAliCloudAuth, identityAliCloudAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityAliCloudAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachAliCloudAuth = async ({ const attachAliCloudAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios from "axios"; import axios from "axios";
import RE2 from "re2"; import RE2 from "re2";
@@ -22,6 +23,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -98,6 +100,7 @@ export const identityAwsAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAwsAuthServiceFactoryDep) => { }: TIdentityAwsAuthServiceFactoryDep) => {
const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => {
const appCfg = getConfig();
const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId });
if (!identityAwsAuth) { if (!identityAwsAuth) {
throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" });
@@ -106,127 +109,156 @@ export const identityAwsAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAwsAuth.identityId); const identity = await identityDAL.findById(identityAwsAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const org = await orgDAL.findById(identity.orgId);
const body: string = Buffer.from(iamRequestBody, "base64").toString(); try {
const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString());
const body: string = Buffer.from(iamRequestBody, "base64").toString();
const authHeader = headers.Authorization || headers.authorization; const authHeader = headers.Authorization || headers.authorization;
const region = authHeader ? awsRegionFromHeader(authHeader) : null; const region = authHeader ? awsRegionFromHeader(authHeader) : null;
if (!isValidAwsRegion(region)) { if (!isValidAwsRegion(region)) {
throw new BadRequestError({ message: "Invalid AWS region" }); throw new BadRequestError({ message: "Invalid AWS region" });
} }
const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint;
const { const {
data: { data: {
GetCallerIdentityResponse: { GetCallerIdentityResponse: {
GetCallerIdentityResult: { Account, Arn, UserId } GetCallerIdentityResult: { Account, Arn, UserId }
}
}
}: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod,
url,
headers,
data: body
});
if (identityAwsAuth.allowedAccountIds) {
// validate if Account is in the list of allowed Account IDs
const isAccountAllowed = identityAwsAuth.allowedAccountIds
.split(",")
.map((accountId) => accountId.trim())
.some((accountId) => accountId === Account);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: AWS account ID not allowed."
});
}
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const formattedArn = extractPrincipalArn(Arn);
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
.some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches
// heavily validated in router
const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`);
return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true));
});
if (!isArnAllowed) {
logger.error(
`AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]`
);
throw new UnauthorizedError({
message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]`
});
} }
} }
}: { data: TGetCallerIdentityResponse } = await axios({
method: iamHttpRequestMethod,
url,
headers,
data: body
});
if (identityAwsAuth.allowedAccountIds) { const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
// validate if Account is in the list of allowed Account IDs await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
const isAccountAllowed = identityAwsAuth.allowedAccountIds {
.split(",") lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH,
.map((accountId) => accountId.trim()) lastLoginTime: new Date()
.some((accountId) => accountId === Account); },
tx
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: AWS account ID not allowed."
});
}
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const formattedArn = extractPrincipalArn(Arn);
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
.some((principalArn) => {
// convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$"
// considers exact matches + wildcard matches
// heavily validated in router
const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`);
return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true));
});
if (!isArnAllowed) {
logger.error(
`AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]`
); );
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAwsAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAwsAuth.accessTokenTTL,
accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.AWS_AUTH
},
tx
);
return newToken;
});
throw new UnauthorizedError({ const splitArn = extractPrincipalArnEntity(Arn);
message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` const accessToken = crypto.jwt().sign(
});
}
}
const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityAwsAuth.identityId, identityId: identityAwsAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAwsAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, identityAuth: {
accessTokenNumUses: 0, aws: {
accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, accountId: Account,
authMethod: IdentityAuthMethod.AWS_AUTH arn: Arn,
}, userId: UserId,
tx
// Derived from ARN
partition: splitArn.Partition,
service: splitArn.Service,
resourceType: splitArn.Type,
resourceName: splitArn.FriendlyName
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const splitArn = extractPrincipalArnEntity(Arn); authAttemptCounter.add(1, {
const accessToken = crypto.jwt().sign( "infisical.identity.id": identityAwsAuth.identityId,
{ "infisical.identity.name": identity.name,
identityId: identityAwsAuth.identityId, "infisical.organization.id": org.id,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.name": org.name,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH,
identityAuth: { "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
aws: { "client.address": requestContext.get("ip"),
accountId: Account, "user_agent.original": requestContext.get("userAgent")
arn: Arn, });
userId: UserId, }
// Derived from ARN return { accessToken, identityAwsAuth, identityAccessToken, identity };
partition: splitArn.Partition, } catch (error) {
service: splitArn.Service, if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
resourceType: splitArn.Type, authAttemptCounter.add(1, {
resourceName: splitArn.FriendlyName "infisical.identity.id": identityAwsAuth.identityId,
} "infisical.identity.name": identity.name,
} "infisical.organization.id": org.id,
} as TIdentityAccessTokenJwtPayload, "infisical.organization.name": org.name,
appCfg.AUTH_SECRET, "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
} throw error;
); }
return { accessToken, identityAwsAuth, identityAccessToken, identity };
}; };
const attachAwsAuth = async ({ const attachAwsAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -18,6 +19,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -61,6 +63,7 @@ export const identityAzureAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityAzureAuthServiceFactoryDep) => { }: TIdentityAzureAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => {
const appCfg = getConfig();
const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId });
if (!identityAzureAuth) { if (!identityAzureAuth) {
throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" });
@@ -69,69 +72,99 @@ export const identityAzureAuthServiceFactory = ({
const identity = await identityDAL.findById(identityAzureAuth.identityId); const identity = await identityDAL.findById(identityAzureAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const azureIdentity = await validateAzureIdentity({ const org = await orgDAL.findById(identity.orgId);
tenantId: identityAzureAuth.tenantId,
resource: identityAzureAuth.resource,
jwt: azureJwt
});
if (azureIdentity.tid !== identityAzureAuth.tenantId) try {
throw new UnauthorizedError({ message: "Tenant ID mismatch" }); const azureIdentity = await validateAzureIdentity({
tenantId: identityAzureAuth.tenantId,
resource: identityAzureAuth.resource,
jwt: azureJwt
});
if (identityAzureAuth.allowedServicePrincipalIds) { if (azureIdentity.tid !== identityAzureAuth.tenantId)
// validate if the service principal id is in the list of allowed service principal ids throw new UnauthorizedError({ message: "Tenant ID mismatch" });
const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds if (identityAzureAuth.allowedServicePrincipalIds) {
.split(",") // validate if the service principal id is in the list of allowed service principal ids
.map((servicePrincipalId) => servicePrincipalId.trim())
.some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid);
if (!isServicePrincipalAllowed) { const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds
throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); .split(",")
.map((servicePrincipalId) => servicePrincipalId.trim())
.some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid);
if (!isServicePrincipalAllowed) {
throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` });
}
} }
}
const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update( await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ {
lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH,
lastLoginTime: new Date() lastLoginTime: new Date()
}, },
tx tx
); );
const newToken = await identityAccessTokenDAL.create( const newToken = await identityAccessTokenDAL.create(
{
identityId: identityAzureAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityAzureAuth.accessTokenTTL,
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.AZURE_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityAzureAuth.identityId, identityId: identityAzureAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityAzureAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.AZURE_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityAzureAuth.identityId,
identityId: identityAzureAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityAzureAuth, identityAccessToken, identity }; return { accessToken, identityAzureAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityAzureAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachAzureAuth = async ({ const attachAzureAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -18,6 +19,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -59,6 +61,7 @@ export const identityGcpAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityGcpAuthServiceFactoryDep) => { }: TIdentityGcpAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => {
const appCfg = getConfig();
const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId });
if (!identityGcpAuth) { if (!identityGcpAuth) {
throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" });
@@ -67,108 +70,140 @@ export const identityGcpAuthServiceFactory = ({
const identity = await identityDAL.findById(identityGcpAuth.identityId); const identity = await identityDAL.findById(identityGcpAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
let gcpIdentityDetails: TGcpIdentityDetails; const org = await orgDAL.findById(identity.orgId);
switch (identityGcpAuth.type) { try {
case "gce": { let gcpIdentityDetails: TGcpIdentityDetails;
gcpIdentityDetails = await validateIdTokenIdentity({ switch (identityGcpAuth.type) {
identityId, case "gce": {
jwt: gcpJwt gcpIdentityDetails = await validateIdTokenIdentity({
}); identityId,
break; jwt: gcpJwt
});
break;
}
case "iam": {
gcpIdentityDetails = await validateIamIdentity({
identityId,
jwt: gcpJwt
});
break;
}
default: {
throw new BadRequestError({ message: "Invalid GCP Auth type" });
}
} }
case "iam": {
gcpIdentityDetails = await validateIamIdentity({ if (identityGcpAuth.allowedServiceAccounts) {
identityId, // validate if the service account is in the list of allowed service accounts
jwt: gcpJwt
}); const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts
break; .split(",")
.map((serviceAccount) => serviceAccount.trim())
.some((serviceAccount) => serviceAccount === gcpIdentityDetails.email);
if (!isServiceAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP service account not allowed."
});
} }
default: {
throw new BadRequestError({ message: "Invalid GCP Auth type" }); if (
identityGcpAuth.type === "gce" &&
identityGcpAuth.allowedProjects &&
gcpIdentityDetails.computeEngineDetails
) {
// validate if the project that the service account belongs to is in the list of allowed projects
const isProjectAllowed = identityGcpAuth.allowedProjects
.split(",")
.map((project) => project.trim())
.some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id);
if (!isProjectAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP project not allowed."
});
} }
}
if (identityGcpAuth.allowedServiceAccounts) { if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) {
// validate if the service account is in the list of allowed service accounts const isZoneAllowed = identityGcpAuth.allowedZones
.split(",")
.map((zone) => zone.trim())
.some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone);
const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts if (!isZoneAllowed)
.split(",") throw new UnauthorizedError({
.map((serviceAccount) => serviceAccount.trim()) message: "Access denied: GCP zone not allowed."
.some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); });
}
if (!isServiceAccountAllowed) const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => {
throw new UnauthorizedError({ await membershipIdentityDAL.update(
message: "Access denied: GCP service account not allowed." { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
}); {
} lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH,
lastLoginTime: new Date()
if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { },
// validate if the project that the service account belongs to is in the list of allowed projects tx
);
const isProjectAllowed = identityGcpAuth.allowedProjects const newToken = await identityAccessTokenDAL.create(
.split(",") {
.map((project) => project.trim()) identityId: identityGcpAuth.identityId,
.some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); isAccessTokenRevoked: false,
accessTokenTTL: identityGcpAuth.accessTokenTTL,
if (!isProjectAllowed) accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL,
throw new UnauthorizedError({ accessTokenNumUses: 0,
message: "Access denied: GCP project not allowed." accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit,
}); authMethod: IdentityAuthMethod.GCP_AUTH
} },
tx
if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { );
const isZoneAllowed = identityGcpAuth.allowedZones return newToken;
.split(",") });
.map((zone) => zone.trim()) const accessToken = crypto.jwt().sign(
.some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone);
if (!isZoneAllowed)
throw new UnauthorizedError({
message: "Access denied: GCP zone not allowed."
});
}
const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityGcpAuth.identityId, identityId: identityGcpAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityGcpAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.GCP_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityGcpAuth.identityId,
identityId: identityGcpAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityGcpAuth, identityAccessToken, identity }; return { accessToken, identityGcpAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityGcpAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachGcpAuth = async ({ const attachGcpAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import https from "https"; import https from "https";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { JwksClient } from "jwks-rsa"; import { JwksClient } from "jwks-rsa";
@@ -21,6 +22,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -67,6 +69,7 @@ export const identityJwtAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityJwtAuthServiceFactoryDep) => { }: TIdentityJwtAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => {
const appCfg = getConfig();
const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId });
if (!identityJwtAuth) { if (!identityJwtAuth) {
throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" });
@@ -75,176 +78,205 @@ export const identityJwtAuthServiceFactory = ({
const identity = await identityDAL.findById(identityJwtAuth.identityId); const identity = await identityDAL.findById(identityJwtAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization, try {
orgId: identity.orgId const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
}); type: KmsDataKey.Organization,
orgId: identity.orgId
const decodedToken = crypto.jwt().decode(jwtValue, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({
message: "Invalid JWT"
}); });
}
let tokenData: Record<string, string | boolean | number> = {}; const decodedToken = crypto.jwt().decode(jwtValue, { complete: true });
if (!decodedToken) {
if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { throw new UnauthorizedError({
let client: JwksClient; message: "Invalid JWT"
if (identityJwtAuth.jwksUrl.includes("https:")) {
const decryptedJwksCaCert = orgDataKeyDecryptor({
cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
}).toString();
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl,
requestAgent
});
} else {
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl
}); });
} }
const { kid } = decodedToken.header as { kid: string }; let tokenData: Record<string, string | boolean | number> = {};
const jwtSigningKey = await client.getSigningKey(kid);
try { if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) {
tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record<string, string>; let client: JwksClient;
} catch (error) { if (identityJwtAuth.jwksUrl.includes("https:")) {
if (error instanceof jwt.JsonWebTokenError) { const decryptedJwksCaCert = orgDataKeyDecryptor({
throw new UnauthorizedError({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert
message: `Access denied: ${error.message}` }).toString();
const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert });
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl,
requestAgent
});
} else {
client = new JwksClient({
jwksUri: identityJwtAuth.jwksUrl
}); });
} }
throw error; const { kid } = decodedToken.header as { kid: string };
} const jwtSigningKey = await client.getSigningKey(kid);
} else {
const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys })
.toString()
.split(",");
const errors: string[] = [];
let isMatchAnyKey = false;
for (const publicKey of decryptedPublicKeys) {
try { try {
tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record<string, string>; tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record<string, string>;
isMatchAnyKey = true;
} catch (error) { } catch (error) {
if (error instanceof jwt.JsonWebTokenError) { if (error instanceof jwt.JsonWebTokenError) {
errors.push(error.message); 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 = crypto.jwt().verify(jwtValue, publicKey) as Record<string, string>;
isMatchAnyKey = true;
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
errors.push(error.message);
}
} }
} }
}
if (!isMatchAnyKey) { 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<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field` 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"
}); });
} }
// handle both single and multi-valued claims if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) {
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new ForbiddenRequestError({
throw new UnauthorizedError({ message: "Access denied: subject not allowed"
message: `Access denied: claim mismatch for field ${claimKey}`
}); });
} }
}
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<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: `Access denied: claim mismatch for field ${claimKey}`
});
}
});
}
const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() },
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 identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { const accessToken = crypto.jwt().sign(
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityJwtAuth.identityId, identityId: identityJwtAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityJwtAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.JWT_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken; if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
}); authAttemptCounter.add(1, {
"infisical.identity.id": identityJwtAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
const appCfg = getConfig(); return { accessToken, identityJwtAuth, identityAccessToken, identity };
const accessToken = crypto.jwt().sign( } catch (error) {
{ if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
identityId: identityJwtAuth.identityId, authAttemptCounter.add(1, {
identityAccessTokenId: identityAccessToken.id, "infisical.identity.id": identityJwtAuth.identityId,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.identity.name": identity.name,
} as TIdentityAccessTokenJwtPayload, "infisical.organization.id": org.id,
appCfg.AUTH_SECRET, "infisical.organization.name": org.name,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH,
Number(identityAccessToken.accessTokenTTL) === 0 "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
? undefined "client.address": requestContext.get("ip"),
: { "user_agent.original": requestContext.get("userAgent")
expiresIn: Number(identityAccessToken.accessTokenTTL) });
} }
); throw error;
}
return { accessToken, identityJwtAuth, identityAccessToken, identity };
}; };
const attachJwtAuth = async ({ const attachJwtAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import https from "https"; import https from "https";
import RE2 from "re2"; import RE2 from "re2";
@@ -37,6 +38,7 @@ import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -182,6 +184,7 @@ export const identityKubernetesAuthServiceFactory = ({
}; };
const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => {
const appCfg = getConfig();
const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId });
if (!identityKubernetesAuth) { if (!identityKubernetesAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -192,294 +195,328 @@ export const identityKubernetesAuthServiceFactory = ({
const identity = await identityDAL.findById(identityKubernetesAuth.identityId); const identity = await identityDAL.findById(identityKubernetesAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
let caCert = ""; try {
if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { const { decryptor } = await kmsService.createCipherPairWithDataKey({
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); type: KmsDataKey.Organization,
} orgId: identity.orgId
});
const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { let caCert = "";
logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); if (identityKubernetesAuth.encryptedKubernetesCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString();
if (!host || !identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
} }
let tokenReviewerJwt = ""; const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => {
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API");
tokenReviewerJwt = decryptor({
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
}).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
let servername = identityKubernetesAuth.kubernetesHost; if (!host || !identityKubernetesAuth.kubernetesHost) {
if (servername.startsWith("https://") || servername.startsWith("http://")) { throw new BadRequestError({
servername = new RE2("^https?:\\/\\/").replace(servername, ""); message: "Kubernetes host is required when token review mode is set to API"
} });
}
// get the last colon index, if it has a port, remove it, including the colon let tokenReviewerJwt = "";
const lastColonIndex = servername.lastIndexOf(":"); if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
if (lastColonIndex !== -1) { tokenReviewerJwt = decryptor({
servername = servername.substring(0, lastColonIndex); cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
} }).toString();
} else {
// if no token reviewer is provided means the incoming token has to act as reviewer
tokenReviewerJwt = serviceAccountJwt;
}
const baseUrl = port ? `${host}:${port}` : host; let servername = identityKubernetesAuth.kubernetesHost;
if (servername.startsWith("https://") || servername.startsWith("http://")) {
servername = new RE2("^https?:\\/\\/").replace(servername, "");
}
const res = await axios // get the last colon index, if it has a port, remove it, including the colon
.post<TCreateTokenReviewResponse>( const lastColonIndex = servername.lastIndexOf(":");
`${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, if (lastColonIndex !== -1) {
{ servername = servername.substring(0, lastColonIndex);
apiVersion: "authentication.k8s.io/v1", }
kind: "TokenReview",
spec: { const baseUrl = port ? `${host}:${port}` : host;
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) const res = await axios
} .post<TCreateTokenReviewResponse>(
}, `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`,
{ {
headers: { apiVersion: "authentication.k8s.io/v1",
"Content-Type": "application/json", kind: "TokenReview",
Authorization: `Bearer ${tokenReviewerJwt}` spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience
? { audiences: [identityKubernetesAuth.allowedAudience] }
: {})
}
}, },
signal: AbortSignal.timeout(10000), {
timeout: 10000, headers: {
httpsAgent: new https.Agent({ "Content-Type": "application/json",
ca: caCert, Authorization: `Bearer ${tokenReviewerJwt}`
rejectUnauthorized: Boolean(caCert),
servername
})
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => {
logger.info(
{
host,
port
},
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
);
const res = await axios
.post<TCreateTokenReviewResponse>(
`${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
{
headers: {
"Content-Type": "application/json",
"x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount
},
signal: AbortSignal.timeout(10000),
timeout: 10000
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
let { message } = err?.response?.data as unknown as { message?: string };
if (!message && typeof err.response.data === "string") {
message = err.response.data;
}
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
let data: TCreateTokenReviewResponse | undefined;
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) {
throw new BadRequestError({
message: "Gateway ID is required when token review mode is set to Gateway"
});
}
data = await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway
);
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
if (!identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
const [k8sHost, k8sPort] = kubernetesHost.split(":");
data =
identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id
? await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443,
reviewTokenThroughGateway: false
}, },
tokenReviewCallbackRaw signal: AbortSignal.timeout(10000),
) timeout: 10000,
: await tokenReviewCallbackRaw(); httpsAgent: new https.Agent({
} else { ca: caCert,
throw new BadRequestError({ rejectUnauthorized: Boolean(caCert),
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` servername
}); })
} }
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
const { message } = err?.response?.data as unknown as { message?: string };
if (!data) { if (message) {
throw new BadRequestError({ throw new UnauthorizedError({
message: "Failed to review token" message,
}); name: "KubernetesTokenReviewRequestError"
} });
}
}
}
throw err;
});
if ("error" in data.status) return res.data;
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); };
// check the response to determine if the token is valid const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => {
if (!(data.status && data.status.authenticated)) logger.info(
throw new UnauthorizedError({ {
message: "Kubernetes token not authenticated", host,
name: "KubernetesTokenReviewError" port
},
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
);
const res = await axios
.post<TCreateTokenReviewResponse>(
`${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`,
{
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience
? { audiences: [identityKubernetesAuth.allowedAudience] }
: {})
}
},
{
headers: {
"Content-Type": "application/json",
"x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount
},
signal: AbortSignal.timeout(10000),
timeout: 10000
}
)
.catch((err) => {
if (err instanceof AxiosError) {
if (err.response) {
let { message } = err?.response?.data as unknown as { message?: string };
if (!message && typeof err.response.data === "string") {
message = err.response.data;
}
if (message) {
throw new UnauthorizedError({
message,
name: "KubernetesTokenReviewRequestError"
});
}
}
}
throw err;
});
return res.data;
};
let data: TCreateTokenReviewResponse | undefined;
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) {
throw new BadRequestError({
message: "Gateway ID is required when token review mode is set to Gateway"
});
}
data = await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
reviewTokenThroughGateway: true
},
tokenReviewCallbackThroughGateway
);
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
if (!identityKubernetesAuth.kubernetesHost) {
throw new BadRequestError({
message: "Kubernetes host is required when token review mode is set to API"
});
}
let { kubernetesHost } = identityKubernetesAuth;
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
}
const [k8sHost, k8sPort] = kubernetesHost.split(":");
data =
identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id
? await $gatewayProxyWrapper(
{
gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string,
targetHost: k8sHost,
targetPort: k8sPort ? Number(k8sPort) : 443,
reviewTokenThroughGateway: false
},
tokenReviewCallbackRaw
)
: await tokenReviewCallbackRaw();
} else {
throw new BadRequestError({
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}`
});
}
if (!data) {
throw new BadRequestError({
message: "Failed to review token"
});
}
if ("error" in data.status)
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" });
// check the response to determine if the token is valid
if (!(data.status && data.status.authenticated))
throw new UnauthorizedError({
message: "Kubernetes token not authenticated",
name: "KubernetesTokenReviewError"
});
const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username);
if (identityKubernetesAuth.allowedNamespaces) {
// validate if [targetNamespace] is in the list of allowed namespaces
const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces
.split(",")
.map((namespace) => namespace.trim())
.some((namespace) => namespace === targetNamespace);
if (!isNamespaceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s namespace not allowed."
});
}
if (identityKubernetesAuth.allowedNames) {
// validate if [targetName] is in the list of allowed names
const isNameAllowed = identityKubernetesAuth.allowedNames
.split(",")
.map((name) => name.trim())
.some((name) => name === targetName);
if (!isNameAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s name not allowed."
});
}
if (identityKubernetesAuth.allowedAudience) {
// validate if [audience] is in the list of allowed audiences
const isAudienceAllowed = data.status.audiences.some(
(audience) => audience === identityKubernetesAuth.allowedAudience
);
if (!isAudienceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s audience not allowed."
});
}
const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityKubernetesAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityKubernetesAuth.accessTokenTTL,
accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.KUBERNETES_AUTH
},
tx
);
return newToken;
}); });
const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); const accessToken = crypto.jwt().sign(
if (identityKubernetesAuth.allowedNamespaces) {
// validate if [targetNamespace] is in the list of allowed namespaces
const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces
.split(",")
.map((namespace) => namespace.trim())
.some((namespace) => namespace === targetNamespace);
if (!isNamespaceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s namespace not allowed."
});
}
if (identityKubernetesAuth.allowedNames) {
// validate if [targetName] is in the list of allowed names
const isNameAllowed = identityKubernetesAuth.allowedNames
.split(",")
.map((name) => name.trim())
.some((name) => name === targetName);
if (!isNameAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s name not allowed."
});
}
if (identityKubernetesAuth.allowedAudience) {
// validate if [audience] is in the list of allowed audiences
const isAudienceAllowed = data.status.audiences.some(
(audience) => audience === identityKubernetesAuth.allowedAudience
);
if (!isAudienceAllowed)
throw new UnauthorizedError({
message: "Access denied: K8s audience not allowed."
});
}
const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityKubernetesAuth.identityId, identityId: identityKubernetesAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityKubernetesAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, identityAuth: {
accessTokenNumUses: 0, kubernetes: {
accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, namespace: targetNamespace,
authMethod: IdentityAuthMethod.KUBERNETES_AUTH name: targetName
}, }
tx }
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityKubernetesAuth.identityId,
identityId: identityKubernetesAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, "infisical.organization.name": org.name,
identityAuth: { "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH,
kubernetes: { "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
namespace: targetNamespace, "client.address": requestContext.get("ip"),
name: targetName "user_agent.original": requestContext.get("userAgent")
} });
} }
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; return { accessToken, identityKubernetesAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityKubernetesAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachKubernetesAuth = async ({ const attachKubernetesAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
@@ -29,6 +30,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -151,6 +153,7 @@ export const identityLdapAuthServiceFactory = ({
}; };
const login = async ({ identityId }: TLoginLdapAuthDTO) => { const login = async ({ identityId }: TLoginLdapAuthDTO) => {
const appCfg = getConfig();
const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId });
if (!identityLdapAuth) { if (!identityLdapAuth) {
@@ -162,6 +165,7 @@ export const identityLdapAuthServiceFactory = ({
const identity = await identityDAL.findById(identityLdapAuth.identityId); const identity = await identityDAL.findById(identityLdapAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const org = await orgDAL.findById(identity.orgId);
const plan = await licenseService.getPlan(identity.orgId); const plan = await licenseService.getPlan(identity.orgId);
if (!plan.ldap) { if (!plan.ldap) {
throw new BadRequestError({ throw new BadRequestError({
@@ -170,44 +174,72 @@ export const identityLdapAuthServiceFactory = ({
}); });
} }
const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { try {
await membershipIdentityDAL.update( const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => {
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, await membershipIdentityDAL.update(
{ lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
tx { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() },
); tx
const newToken = await identityAccessTokenDAL.create( );
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityLdapAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityLdapAuth.accessTokenTTL,
accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.LDAP_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityLdapAuth.identityId, identityId: identityLdapAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityLdapAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
authMethod: IdentityAuthMethod.LDAP_AUTH Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityLdapAuth.identityId,
identityId: identityLdapAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error "client.address": requestContext.get("ip"),
Number(identityAccessToken.accessTokenTTL) === 0 "user_agent.original": requestContext.get("userAgent")
? undefined });
: { }
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityLdapAuth, identityAccessToken, identity }; return { accessToken, identityLdapAuth, identityAccessToken, identity };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityLdapAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachLdapAuth = async ({ const attachLdapAuth = async ({
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import RE2 from "re2"; import RE2 from "re2";
@@ -23,6 +24,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -63,6 +65,7 @@ export const identityOciAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityOciAuthServiceFactoryDep) => { }: TIdentityOciAuthServiceFactoryDep) => {
const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => {
const appCfg = getConfig();
const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); const identityOciAuth = await identityOciAuthDAL.findOne({ identityId });
if (!identityOciAuth) { if (!identityOciAuth) {
throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" });
@@ -71,80 +74,109 @@ export const identityOciAuthServiceFactory = ({
const identity = await identityDAL.findById(identityOciAuth.identityId); const identity = await identityDAL.findById(identityOciAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
// Validate OCI host format. Ensures that the host is in "identity.<region>.oraclecloud.com" format. const org = await orgDAL.findById(identity.orgId);
if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { try {
throw new BadRequestError({ // Validate OCI host format. Ensures that the host is in "identity.<region>.oraclecloud.com" format.
message: "Invalid OCI host format. Expected format: identity.<region>.oraclecloud.com" if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) {
}); throw new BadRequestError({
} message: "Invalid OCI host format. Expected format: identity.<region>.oraclecloud.com"
const { data } = await request
.get<TOciGetUserResponse>(`https://${headers.host}/20160918/users/${userOcid}`, {
headers
})
.catch((err: AxiosError) => {
logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud");
throw err;
});
if (data.compartmentId !== identityOciAuth.tenancyOcid) {
throw new UnauthorizedError({
message: "Access denied: OCI account isn't part of tenancy."
});
}
if (identityOciAuth.allowedUsernames) {
const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: OCI account username not allowed."
}); });
} }
// Generate the token const { data } = await request
const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { .get<TOciGetUserResponse>(`https://${headers.host}/20160918/users/${userOcid}`, {
await membershipIdentityDAL.update( headers
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, })
{ lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, .catch((err: AxiosError) => {
tx logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud");
); throw err;
const newToken = await identityAccessTokenDAL.create( });
if (data.compartmentId !== identityOciAuth.tenancyOcid) {
throw new UnauthorizedError({
message: "Access denied: OCI account isn't part of tenancy."
});
}
if (identityOciAuth.allowedUsernames) {
const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name);
if (!isAccountAllowed)
throw new UnauthorizedError({
message: "Access denied: OCI account username not allowed."
});
}
// Generate the token
const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOciAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOciAuth.accessTokenTTL,
accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OCI_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityOciAuth.identityId, identityId: identityOciAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityOciAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.OCI_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityOciAuth.identityId,
identityId: identityOciAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityOciAuth, identityOciAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOciAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachOciAuth = async ({ const attachOciAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import axios from "axios"; import axios from "axios";
import https from "https"; import https from "https";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
@@ -22,6 +23,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getValueByDot } from "@app/lib/template/dot-access"; import { getValueByDot } from "@app/lib/template/dot-access";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -67,6 +69,7 @@ export const identityOidcAuthServiceFactory = ({
orgDAL orgDAL
}: TIdentityOidcAuthServiceFactoryDep) => { }: TIdentityOidcAuthServiceFactoryDep) => {
const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => {
const appCfg = getConfig();
const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId });
if (!identityOidcAuth) { if (!identityOidcAuth) {
throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" });
@@ -75,151 +78,180 @@ export const identityOidcAuthServiceFactory = ({
const identity = await identityDAL.findById(identityOidcAuth.identityId); const identity = await identityDAL.findById(identityOidcAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
let caCert = "";
if (identityOidcAuth.encryptedCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString();
}
const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert });
const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
const jwksUri = discoveryDoc.jwks_uri;
const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({
message: "Invalid JWT"
});
}
const client = new JwksClient({
jwksUri,
requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
});
const { kid } = decodedToken.header as { kid: string };
const oidcSigningKey = await client.getSigningKey(kid);
let tokenData: Record<string, string>;
try { try {
tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { const { decryptor } = await kmsService.createCipherPairWithDataKey({
issuer: identityOidcAuth.boundIssuer type: KmsDataKey.Organization,
}) as Record<string, string>; orgId: identity.orgId
} catch (error) { });
if (error instanceof jwt.JsonWebTokenError) {
let caCert = "";
if (identityOidcAuth.encryptedCaCertificate) {
caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString();
}
const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert });
const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>(
`${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`,
{
httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
}
);
const jwksUri = discoveryDoc.jwks_uri;
const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true });
if (!decodedToken) {
throw new UnauthorizedError({ throw new UnauthorizedError({
message: `Access denied: ${error.message}` message: "Invalid JWT"
});
}
const client = new JwksClient({
jwksUri,
requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined
});
const { kid } = decodedToken.header as { kid: string };
const oidcSigningKey = await client.getSigningKey(kid);
let tokenData: Record<string, string>;
try {
tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), {
issuer: identityOidcAuth.boundIssuer
}) as Record<string, string>;
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
throw new UnauthorizedError({
message: `Access denied: ${error.message}`
});
}
throw error;
}
if (identityOidcAuth.boundSubject) {
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
throw new ForbiddenRequestError({
message: "Access denied: OIDC subject not allowed."
});
}
}
if (identityOidcAuth.boundAudiences) {
if (
!identityOidcAuth.boundAudiences
.split(", ")
.some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: OIDC audience not allowed."
});
}
}
if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: "Access denied: OIDC claim not allowed."
});
}
});
}
const filteredClaims: Record<string, string> = {};
if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
filteredClaims[permissionKey] = value.toString();
});
}
const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOidcAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOidcAuth.accessTokenTTL,
accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OIDC_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{
identityId: identityOidcAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
oidc: {
claims: filteredClaims
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOidcAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityOidcAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
}); });
} }
throw error; throw error;
} }
if (identityOidcAuth.boundSubject) {
if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) {
throw new ForbiddenRequestError({
message: "Access denied: OIDC subject not allowed."
});
}
}
if (identityOidcAuth.boundAudiences) {
if (
!identityOidcAuth.boundAudiences
.split(", ")
.some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue))
) {
throw new UnauthorizedError({
message: "Access denied: OIDC audience not allowed."
});
}
}
if (identityOidcAuth.boundClaims) {
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
// handle both single and multi-valued claims
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) {
throw new UnauthorizedError({
message: "Access denied: OIDC claim not allowed."
});
}
});
}
const filteredClaims: Record<string, string> = {};
if (identityOidcAuth.claimMetadataMapping) {
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
const value = getValueByDot(tokenData, claimKey);
if (!value) {
throw new UnauthorizedError({
message: `Access denied: token has no ${claimKey} field`
});
}
filteredClaims[permissionKey] = value.toString();
});
}
const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityOidcAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityOidcAuth.accessTokenTTL,
accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.OIDC_AUTH
},
tx
);
return newToken;
});
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityOidcAuth.identityId,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN,
identityAuth: {
oidc: {
claims: filteredClaims
}
}
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData };
}; };
const attachOidcAuth = async ({ const attachOidcAuth = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -19,6 +20,7 @@ import {
UnauthorizedError UnauthorizedError
} from "@app/lib/errors"; } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -27,6 +29,7 @@ import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identit
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 { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal";
import { TOrgDALFactory } from "../org/org-dal";
import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns";
import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal";
import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types";
@@ -42,6 +45,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
orgDAL: Pick<TOrgDALFactory, "findById">;
}; };
const parseSubjectDetails = (data: string) => { const parseSubjectDetails = (data: string) => {
@@ -60,9 +64,11 @@ export const identityTlsCertAuthServiceFactory = ({
membershipIdentityDAL, membershipIdentityDAL,
licenseService, licenseService,
permissionService, permissionService,
kmsService kmsService,
orgDAL
}: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { }: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => {
const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => {
const appCfg = getConfig();
const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId });
if (!identityTlsCertAuth) { if (!identityTlsCertAuth) {
throw new NotFoundError({ throw new NotFoundError({
@@ -73,94 +79,124 @@ export const identityTlsCertAuthServiceFactory = ({
const identity = await identityDAL.findById(identityTlsCertAuth.identityId); const identity = await identityDAL.findById(identityTlsCertAuth.identityId);
if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); if (!identity) throw new UnauthorizedError({ message: "Identity not found" });
const { decryptor } = await kmsService.createCipherPairWithDataKey({ const org = await orgDAL.findById(identity.orgId);
type: KmsDataKey.Organization,
orgId: identity.orgId
});
const caCertificate = decryptor({ try {
cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate const { decryptor } = await kmsService.createCipherPairWithDataKey({
}).toString(); type: KmsDataKey.Organization,
orgId: identity.orgId
const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
if (!leafCertificate) {
throw new BadRequestError({ message: "Missing client certificate" });
}
const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate);
const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate);
const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
if (!isValidCertificate)
throw new UnauthorizedError({
message: "Access denied: Certificate not issued by the provided CA."
}); });
if (new Date(clientCertificateX509.validTo) < new Date()) { const caCertificate = decryptor({
throw new UnauthorizedError({ cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate
message: "Access denied: Certificate has expired." }).toString();
});
}
if (new Date(clientCertificateX509.validFrom) > new Date()) { const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0];
throw new UnauthorizedError({ if (!leafCertificate) {
message: "Access denied: Certificate not yet valid." throw new BadRequestError({ message: "Missing client certificate" });
}); }
}
const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate);
if (identityTlsCertAuth.allowedCommonNames) { const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate);
const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
if (!isValidCommonName) { const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey);
if (!isValidCertificate)
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Access denied: TLS Certificate Auth common name not allowed." message: "Access denied: Certificate not issued by the provided CA."
});
if (new Date(clientCertificateX509.validTo) < new Date()) {
throw new UnauthorizedError({
message: "Access denied: Certificate has expired."
}); });
} }
}
// Generate the token if (new Date(clientCertificateX509.validFrom) > new Date()) {
const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { throw new UnauthorizedError({
await membershipIdentityDAL.update( message: "Access denied: Certificate not yet valid."
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, });
{ lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, }
tx
); const subjectDetails = parseSubjectDetails(clientCertificateX509.subject);
const newToken = await identityAccessTokenDAL.create( if (identityTlsCertAuth.allowedCommonNames) {
const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN);
if (!isValidCommonName) {
throw new UnauthorizedError({
message: "Access denied: TLS Certificate Auth common name not allowed."
});
}
}
// Generate the token
const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => {
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{ lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() },
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityTlsCertAuth.identityId,
isAccessTokenRevoked: false,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL,
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit,
authMethod: IdentityAuthMethod.TLS_CERT_AUTH
},
tx
);
return newToken;
});
const accessToken = crypto.jwt().sign(
{ {
identityId: identityTlsCertAuth.identityId, identityId: identityTlsCertAuth.identityId,
isAccessTokenRevoked: false, identityAccessTokenId: identityAccessToken.id,
accessTokenTTL: identityTlsCertAuth.accessTokenTTL, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, } as TIdentityAccessTokenJwtPayload,
accessTokenNumUses: 0, appCfg.AUTH_SECRET,
accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, Number(identityAccessToken.accessTokenTTL) === 0
authMethod: IdentityAuthMethod.TLS_CERT_AUTH ? undefined
}, : {
tx expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken;
});
const appCfg = getConfig(); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
const accessToken = crypto.jwt().sign( authAttemptCounter.add(1, {
{ "infisical.identity.id": identityTlsCertAuth.identityId,
identityId: identityTlsCertAuth.identityId, "infisical.identity.name": identity.name,
identityAccessTokenId: identityAccessToken.id, "infisical.organization.id": org.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN "infisical.organization.name": org.name,
} as TIdentityAccessTokenJwtPayload, "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH,
appCfg.AUTH_SECRET, "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
Number(identityAccessToken.accessTokenTTL) === 0 "client.address": requestContext.get("ip"),
? undefined "user_agent.original": requestContext.get("userAgent")
: { });
expiresIn: Number(identityAccessToken.accessTokenTTL) }
}
);
return { return {
identityTlsCertAuth, identityTlsCertAuth,
accessToken, accessToken,
identityAccessToken, identityAccessToken,
identity identity
}; };
} catch (error) {
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
authAttemptCounter.add(1, {
"infisical.identity.id": identityTlsCertAuth.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
throw error;
}
}; };
const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({ const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -21,6 +22,7 @@ import {
} from "@app/lib/errors"; } from "@app/lib/errors";
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -77,6 +79,7 @@ export const identityUaServiceFactory = ({
identityDAL identityDAL
}: TIdentityUaServiceFactoryDep) => { }: TIdentityUaServiceFactoryDep) => {
const login = async (clientId: string, clientSecret: string, ip: string) => { const login = async (clientId: string, clientSecret: string, ip: string) => {
const appCfg = getConfig();
const identityUa = await identityUaDAL.findOne({ clientId }); const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) { if (!identityUa) {
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -84,196 +87,226 @@ export const identityUaServiceFactory = ({
}); });
} }
checkIPAgainstBlocklist({ const identity = await identityDAL.findById(identityUa.identityId);
ipAddress: ip, const org = await orgDAL.findById(identity.orgId);
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
});
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; try {
checkIPAgainstBlocklist({
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
if (lockout && lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
}); });
}
const clientSecretPrefix = clientSecret.slice(0, 4); const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) { let lockout: LockoutObject | undefined;
validClientSecretInfo = info; if (lockoutRaw) {
break; lockout = JSON.parse(lockoutRaw) as LockoutObject;
} }
}
if (!validClientSecretInfo) { if (lockout && lockout.lockedOut) {
if (identityUa.lockoutEnabled) { throw new UnauthorizedError({
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined; message: "This identity auth method is temporarily locked, please try again later"
try { });
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { }
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
// Re-fetch the latest lockout data while holding the lock const clientSecretPrefix = clientSecret.slice(0, 4);
const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); const clientSecretInfo = await identityUaClientSecretDAL.find({
if (lockoutRawNew) { identityUAId: identityUa.id,
lockout = JSON.parse(lockoutRawNew) as LockoutObject; isClientSecretRevoked: false,
} else { clientSecretPrefix
lockout = { });
lockedOut: false,
failedAttempts: 0
};
}
if (lockout.lockedOut) { let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
throw new UnauthorizedError({ for await (const info of clientSecretInfo) {
message: "This identity auth method is temporarily locked, please try again later" const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
});
}
lockout.failedAttempts += 1; if (isMatch) {
if (lockout.failedAttempts >= identityUa.lockoutThreshold) { validClientSecretInfo = info;
lockout.lockedOut = true; break;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
} catch (e) {
if (lock === undefined) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
throw e;
} finally {
if (lock) {
await lock.release();
}
} }
} }
throw new UnauthorizedError({ message: "Invalid credentials" }); if (!validClientSecretInfo) {
} else if (lockout) { if (identityUa.lockoutEnabled) {
// If credentials are valid, clear any existing lockout record let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
await keyStore.deleteItem(LOCKOUT_KEY); try {
} lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, {
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; // Re-fetch the latest lockout data while holding the lock
if (Number(clientSecretTTL) > 0) { const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY);
const clientSecretCreated = new Date(validClientSecretInfo.createdAt); if (lockoutRawNew) {
const ttlInMilliseconds = Number(clientSecretTTL) * 1000; lockout = JSON.parse(lockoutRawNew) as LockoutObject;
const currentDate = new Date(); } else {
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); lockout = {
lockedOut: false,
failedAttempts: 0
};
}
if (currentDate > expirationTime) { if (lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
} catch (e) {
if (lock === undefined) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
throw e;
} finally {
if (lock) {
await lock.release();
}
}
}
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
// If credentials are valid, clear any existing lockout record
await keyStore.deleteItem(LOCKOUT_KEY);
}
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true isClientSecretRevoked: true
}); });
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Access denied due to expired client secret" message: "Access denied due to client secret usage limit reached"
}); });
} }
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { const accessTokenTTLParams =
// number of times client secret can be used for Number(identityUa.accessTokenPeriod) === 0
// a login operation reached ? {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { accessTokenTTL: identityUa.accessTokenTTL,
isClientSecretRevoked: true accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
);
return newToken;
}); });
throw new UnauthorizedError({
message: "Access denied due to client secret usage limit reached"
});
}
const accessTokenTTLParams = const accessToken = crypto.jwt().sign(
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identity = await identityDAL.findById(identityUa.identityId);
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await membershipIdentityDAL.update(
{ scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id },
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{ {
identityId: identityUa.identityId, identityId: identityUa.identityId,
isAccessTokenRevoked: false, clientSecretId: validClientSecretInfo.id,
identityUAClientSecretId: uaClientSecretDoc.id, identityAccessTokenId: identityAccessToken.id,
accessTokenNumUses: 0, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, } as TIdentityAccessTokenJwtPayload,
accessTokenPeriod: identityUa.accessTokenPeriod, appCfg.AUTH_SECRET,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
...accessTokenTTLParams Number(identityAccessToken.accessTokenTTL) === 0
}, ? undefined
tx : {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
); );
return newToken; if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
}); authAttemptCounter.add(1, {
"infisical.identity.id": identityUa.identityId,
"infisical.identity.name": identity.name,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
}
const appCfg = getConfig(); return {
const accessToken = crypto.jwt().sign( accessToken,
{ identityUa,
identityId: identityUa.identityId, validClientSecretInfo,
clientSecretId: validClientSecretInfo.id, identityAccessToken,
identityAccessTokenId: identityAccessToken.id, identity,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN ...accessTokenTTLParams
} as TIdentityAccessTokenJwtPayload, };
appCfg.AUTH_SECRET, } catch (error) {
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
Number(identityAccessToken.accessTokenTTL) === 0 authAttemptCounter.add(1, {
? undefined "infisical.identity.id": identityUa.identityId,
: { "infisical.identity.name": identity.name,
expiresIn: Number(identityAccessToken.accessTokenTTL) "infisical.organization.id": org.id,
} "infisical.organization.name": org.name,
); "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH,
"infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE,
return { "client.address": requestContext.get("ip"),
accessToken, "user_agent.original": requestContext.get("userAgent")
identityUa, });
validClientSecretInfo, }
identityAccessToken, throw error;
identity, }
...accessTokenTTLParams
};
}; };
const attachUniversalAuth = async ({ const attachUniversalAuth = async ({
@@ -34,6 +34,7 @@ import { diff, groupBy } from "@app/lib/fn";
import { setKnexStringValue } from "@app/lib/knex"; import { setKnexStringValue } from "@app/lib/knex";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { recordSecretReadMetric } from "@app/lib/telemetry/metrics";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
@@ -1052,6 +1053,11 @@ export const secretV2BridgeServiceFactory = ({
}); });
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret);
recordSecretReadMetric({
environment,
secretPath: path
});
const cachedSecretDalVersion = await keyStore.pgGetIntItem(SecretServiceCacheKeys.getSecretDalVersion(projectId)); const cachedSecretDalVersion = await keyStore.pgGetIntItem(SecretServiceCacheKeys.getSecretDalVersion(projectId));
const secretDalVersion = Number(cachedSecretDalVersion || 0); const secretDalVersion = Number(cachedSecretDalVersion || 0);
const cacheKey = SecretServiceCacheKeys.getSecretsOfServiceLayer(projectId, secretDalVersion, { const cacheKey = SecretServiceCacheKeys.getSecretsOfServiceLayer(projectId, secretDalVersion, {
@@ -1482,6 +1488,12 @@ export const secretV2BridgeServiceFactory = ({
secretTags: (secret?.tags || []).map((el) => el.slug) secretTags: (secret?.tags || []).map((el) => el.slug)
}); });
recordSecretReadMetric({
environment,
secretPath: path,
name: secretName
});
// this will throw if the user doesn't have read value permission no matter what // this will throw if the user doesn't have read value permission no matter what
// because if its an expansion, it will fully depend on the value. // because if its an expansion, it will fully depend on the value.
const { expandSecretReferences } = expandSecretReferencesFactory({ const { expandSecretReferences } = expandSecretReferencesFactory({
+122 -70
View File
@@ -319,80 +319,137 @@ helm install otel-collector open-telemetry/opentelemetry-collector \
--set config.exporters.prometheus.endpoint=0.0.0.0:8889 --set config.exporters.prometheus.endpoint=0.0.0.0:8889
``` ```
## Alternative Backends
Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus:
### Cloud-Native Examples
```yaml
# Add to your otel-collector-config.yaml exporters section
exporters:
# AWS CloudWatch
awsemf:
region: us-west-2
log_group_name: /aws/emf/infisical
log_stream_name: metrics
# Google Cloud Monitoring
googlecloud:
project_id: your-project-id
# Azure Monitor
azuremonitor:
connection_string: "your-connection-string"
# Datadog
datadog:
api:
key: "your-api-key"
site: "datadoghq.com"
# New Relic
newrelic:
apikey: "your-api-key"
host_override: "otlp.nr-data.net"
```
### Multi-Backend Configuration
```yaml
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus, awsemf, datadog] # Send to multiple backends
```
## Setting Up Grafana
1. **Access Grafana**: Navigate to your Grafana instance
2. **Login**: Use your configured credentials
3. **Add Prometheus Data Source**:
- Go to Configuration → Data Sources
- Click "Add data source"
- Select "Prometheus"
- Set URL to your Prometheus endpoint
- Click "Save & Test"
## Available Metrics ## Available Metrics
Infisical exposes the following key metrics in OpenTelemetry format: Infisical exposes the following key metrics in OpenTelemetry format:
### API Performance Metrics ### Core API Metrics
- `API_latency` - API request latency histogram in milliseconds These metrics track all HTTP API requests to Infisical, including request counts, latency, and errors. Use these to monitor overall API health, identify performance bottlenecks, and track usage patterns across users and machine identities.
- **Labels**: `route`, `method`, `statusCode` #### Total API Requests
- **Example**: Monitor response times for specific endpoints
- `API_errors` - API error count histogram - **Metric Name**: `infisical.http.server.request.count`
- **Labels**: `route`, `method`, `type`, `name` - **Type**: Counter
- **Example**: Track error rates by endpoint and error type - **Unit**: `{request}`
- **Description**: Total number of API requests to Infisical (covers both human users and machine identities)
- **Attributes**:
- `infisical.organization.id` (string): Organization ID
- `infisical.organization.name` (string): Organization name (e.g., "Platform Engineering Team")
- `infisical.user.id` (string, optional): User ID if human user
- `infisical.user.email` (string, optional): User email (e.g., "[email protected]")
- `infisical.identity.id` (string, optional): Machine identity ID
- `infisical.identity.name` (string, optional): Machine identity name (e.g., "prod-k8s-operator")
- `infisical.auth.method` (string, optional): Auth method used
- `http.request.method` (string): HTTP method (GET, POST, PUT, DELETE)
- `http.route` (string): API endpoint route pattern
- `http.response.status_code` (int): HTTP status code
- `infisical.project.id` (string, optional): Project ID
- `infisical.project.name` (string, optional): Project name
- `user_agent.original` (string, optional): User agent string
- `client.address` (string, optional): IP address
#### Request Duration
- **Metric Name**: `infisical.http.server.request.duration`
- **Type**: Histogram
- **Unit**: `s` (seconds)
- **Description**: API request latency
- **Buckets**: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
- **Attributes**:
- `infisical.organization.id` (string): Organization ID
- `infisical.organization.name` (string): Organization name
- `infisical.user.id` (string, optional): User ID if human user
- `infisical.user.email` (string, optional): User email
- `infisical.identity.id` (string, optional): Machine identity ID
- `infisical.identity.name` (string, optional): Machine identity name
- `http.request.method` (string): HTTP method
- `http.route` (string): API endpoint route pattern
- `http.response.status_code` (int): HTTP status code
- `infisical.project.id` (string, optional): Project ID
- `infisical.project.name` (string, optional): Project name
#### API Errors by Actor
- **Metric Name**: `infisical.http.server.error.count`
- **Type**: Counter
- **Unit**: `{error}`
- **Description**: API errors grouped by actor (for identifying misconfigured services)
- **Attributes**:
- `infisical.organization.id` (string): Organization ID
- `infisical.organization.name` (string): Organization name
- `infisical.user.id` (string, optional): User ID if human
- `infisical.user.email` (string, optional): User email
- `infisical.identity.id` (string, optional): Identity ID if machine
- `infisical.identity.name` (string, optional): Identity name
- `http.route` (string): API endpoint where error occurred
- `http.request.method` (string): HTTP method
- `error.type` (string): Error category/type (client_error, server_error, auth_error, rate_limit_error, etc.)
- `infisical.project.id` (string, optional): Project ID
- `infisical.project.name` (string, optional): Project name
- `client.address` (string, optional): IP address
- `user_agent.original` (string, optional): User agent information
### Secret Operations Metrics
These metrics provide visibility into secret access patterns, helping you understand which secrets are being accessed, by whom, and from where. Essential for security auditing and access pattern analysis.
#### Secret Read Operations
- **Metric Name**: `infisical.secret.read.count`
- **Type**: Counter
- **Unit**: `{operation}`
- **Description**: Number of secret read operations
- **Attributes**:
- `infisical.organization.id` (string): Organization ID
- `infisical.organization.name` (string): Organization name
- `infisical.project.id` (string): Project ID
- `infisical.project.name` (string): Project name (e.g., "payment-service-secrets")
- `infisical.environment` (string): Environment (dev, staging, prod)
- `infisical.secret.path` (string): Path to secrets (e.g., "/microservice-a/database")
- `infisical.secret.name` (string, optional): Name of secret
- `infisical.user.id` (string, optional): User ID if human
- `infisical.user.email` (string, optional): User email
- `infisical.identity.id` (string, optional): Machine identity ID
- `infisical.identity.name` (string, optional): Machine identity name
- `user_agent.original` (string, optional): User agent/SDK information
- `client.address` (string, optional): IP address
### Authentication Metrics
These metrics track authentication attempts and outcomes, enabling you to monitor login success rates, detect potential security threats, and identify authentication issues.
#### Login Attempts
- **Metric Name**: `infisical.auth.attempt.count`
- **Type**: Counter
- **Unit**: `{attempt}`
- **Description**: Authentication attempts (both successful and failed)
- **Attributes**:
- `infisical.organization.id` (string): Organization ID
- `infisical.organization.name` (string): Organization name
- `infisical.user.id` (string, optional): User ID if human (if identifiable)
- `infisical.user.email` (string, optional): User email (if identifiable)
- `infisical.identity.id` (string, optional): Identity ID if machine (if identifiable)
- `infisical.identity.name` (string, optional): Identity name (if identifiable)
- `infisical.auth.method` (string): Authentication method attempted
- `infisical.auth.result` (string): success or failure
- `error.type` (string, optional): Reason for failure if failed (invalid_credentials, expired_token, invalid_token, etc.)
- `client.address` (string): IP address
- `user_agent.original` (string, optional): User agent/client information
- `infisical.auth.attempt.username` (string, optional): Attempted username/email (if available)
### Legacy Metrics
These metrics are from the previous instrumentation and may be deprecated in future versions. Consider migrating to the new Core API Metrics for more comprehensive observability.
- `API_latency` - API request latency histogram in milliseconds (Labels: `route`, `method`, `statusCode`)
- `API_errors` - API error count histogram (Labels: `route`, `method`, `type`, `name`)
### Integration & Secret Sync Metrics ### Integration & Secret Sync Metrics
These metrics monitor secret synchronization operations between Infisical and external systems, helping you track sync health, identify integration failures, and troubleshoot connectivity issues.
- `integration_secret_sync_errors` - Integration secret sync error count - `integration_secret_sync_errors` - Integration secret sync error count
- **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId`
@@ -414,16 +471,11 @@ Infisical exposes the following key metrics in OpenTelemetry format:
### System Metrics ### System Metrics
These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: These low-level HTTP metrics are automatically collected by OpenTelemetry's instrumentation layer, providing baseline performance data for all HTTP traffic.
- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) - `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum)
- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) - `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum)
### Custom Business Metrics
- `infisical_secret_operations_total` - Total secret operations
- `infisical_secrets_processed_total` - Total secrets processed
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues