mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 01:52:22 +00:00
Move MI from refresh token to client id / client secrets approach
This commit is contained in:
@@ -25,7 +25,7 @@ declare module "jsonwebtoken" {
|
||||
userId: string;
|
||||
refreshVersion?: number;
|
||||
}
|
||||
export interface MachineRefreshTokenJwtPayload extends jwt.JwtPayload {
|
||||
export interface MachineAccessTokenJwtPayload extends jwt.JwtPayload {
|
||||
_id: string;
|
||||
authTokenType: string;
|
||||
tokenVersion: number;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import bcrypt from "bcrypt";
|
||||
import crypto from "crypto";
|
||||
import { Request, Response } from "express";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
IMachineIdentityClientSecretData,
|
||||
IMachineIdentityTrustedIp,
|
||||
MachineIdentity,
|
||||
MachineIdentityClientSecretData,
|
||||
MachineMembership,
|
||||
MachineMembershipOrg,
|
||||
Organization,
|
||||
} from "../../../models";
|
||||
import {
|
||||
ActorType,
|
||||
EventType,
|
||||
Role
|
||||
} from "../../models";
|
||||
@@ -24,105 +26,291 @@ import {
|
||||
import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip";
|
||||
import { EEAuditLogService, EELicenseService } from "../../services";
|
||||
import { getAuthSecret } from "../../../config";
|
||||
import { getAuthSecret, getSaltRounds } from "../../../config";
|
||||
import { ADMIN, AuthTokenType, CUSTOM, MEMBER, NO_ACCESS } from "../../../variables";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "../../services/RoleService";
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { checkIPAgainstBlocklist } from "../../../utils/ip";
|
||||
|
||||
const packageClientSecretData = (clientSecretData: IMachineIdentityClientSecretData) => ({
|
||||
_id: clientSecretData._id,
|
||||
machineIdentity: clientSecretData.machineIdentity,
|
||||
isActive: clientSecretData.isActive,
|
||||
description: clientSecretData.description,
|
||||
clientSecretPrefix: clientSecretData.clientSecretPrefix,
|
||||
clientSecretUsageCount: clientSecretData.clientSecretUsageCount,
|
||||
clientSecretUsageLimit: clientSecretData.clientSecretUsageLimit,
|
||||
expiresAt: clientSecretData.expiresAt
|
||||
});
|
||||
|
||||
/**
|
||||
* Return machine identity access and refresh token as per refresh operation
|
||||
* Return client secrets for machine with id [machineId]
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const refreshToken = async (req: Request, res: Response) => {
|
||||
export const getMIClientSecrets = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: {
|
||||
machineId
|
||||
}
|
||||
} = await validateRequest(reqValidator.GetClientSecretsV3, req);
|
||||
|
||||
const machineMembershipOrg = await MachineMembershipOrg.findOne({
|
||||
machineIdentity: new Types.ObjectId(machineId)
|
||||
});
|
||||
|
||||
if (!machineMembershipOrg) throw ResourceNotFoundError();
|
||||
|
||||
const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString());
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionActions.Read,
|
||||
OrgPermissionSubjects.MachineIdentity
|
||||
);
|
||||
|
||||
const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString());
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) throw ForbiddenRequestError({
|
||||
message: "Failed to get client secrets for more privileged MI"
|
||||
});
|
||||
|
||||
const clientSecretData = await MachineIdentityClientSecretData
|
||||
.find({
|
||||
machineIdentity: machineMembershipOrg.machineIdentity,
|
||||
isActive: true
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(5);
|
||||
|
||||
return res.status(200).send({
|
||||
clientSecretData: clientSecretData.map((clientSecretDatum) => packageClientSecretData(clientSecretDatum))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client secret for machine with id [machineId]
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const createMIClientSecret = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: {
|
||||
machineId
|
||||
},
|
||||
body: {
|
||||
description,
|
||||
ttl,
|
||||
usageLimit
|
||||
}
|
||||
} = await validateRequest(reqValidator.CreateClientSecretV3, req);
|
||||
|
||||
const machineMembershipOrg = await MachineMembershipOrg.findOne({
|
||||
machineIdentity: new Types.ObjectId(machineId)
|
||||
});
|
||||
|
||||
if (!machineMembershipOrg) throw ResourceNotFoundError();
|
||||
|
||||
const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString());
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionActions.Create,
|
||||
OrgPermissionSubjects.MachineIdentity
|
||||
);
|
||||
|
||||
const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString());
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) throw ForbiddenRequestError({
|
||||
message: "Failed to create client secret for more privileged MI"
|
||||
});
|
||||
|
||||
let expiresAt;
|
||||
if (ttl > 0) {
|
||||
expiresAt = new Date(new Date().getTime() + ttl * 1000);
|
||||
}
|
||||
|
||||
const clientSecret = crypto.randomBytes(32).toString("hex");
|
||||
const clientSecretHash = await bcrypt.hash(clientSecret, await getSaltRounds());
|
||||
|
||||
const machineIdentityClientSecretData = await new MachineIdentityClientSecretData({
|
||||
machineIdentity: machineMembershipOrg.machineIdentity,
|
||||
isActive: true,
|
||||
description,
|
||||
clientSecretPrefix: clientSecret.slice(0, 4),
|
||||
clientSecretHash,
|
||||
clientSecretUsageCount: 0,
|
||||
clientSecretUsageLimit: usageLimit,
|
||||
accessTokenVersion: 1,
|
||||
expiresAt
|
||||
}).save();
|
||||
|
||||
return res.status(200).send({
|
||||
clientSecret,
|
||||
clientSecretData: packageClientSecretData(machineIdentityClientSecretData)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete client secret with id [clientSecretId]
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const deleteMIClientSecret = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: {
|
||||
machineId,
|
||||
clientSecretId
|
||||
}
|
||||
} = await validateRequest(reqValidator.DeleteClientSecretV3, req);
|
||||
|
||||
const machineMembershipOrg = await MachineMembershipOrg.findOne({
|
||||
machineIdentity: new Types.ObjectId(machineId)
|
||||
});
|
||||
|
||||
if (!machineMembershipOrg) throw ResourceNotFoundError();
|
||||
|
||||
const { permission } = await getUserOrgPermissions(req.user._id, machineMembershipOrg.organization.toString());
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionActions.Delete,
|
||||
OrgPermissionSubjects.MachineIdentity
|
||||
);
|
||||
|
||||
const rolePermission = await getOrgRolePermissions(machineMembershipOrg.role, machineMembershipOrg.organization.toString());
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) throw ForbiddenRequestError({
|
||||
message: "Failed to delete client secrets for more privileged MI"
|
||||
});
|
||||
|
||||
const clientSecretData = await MachineIdentityClientSecretData.findOneAndDelete({
|
||||
_id: clientSecretId,
|
||||
machineIdentity: machineId
|
||||
});
|
||||
|
||||
if (!clientSecretData) throw ResourceNotFoundError();
|
||||
|
||||
return res.status(200).send({
|
||||
clientSecretData: packageClientSecretData(clientSecretData)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return access token for machine identity with client id [clientId]
|
||||
* and client secret [clientSecret]
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const loginMI = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: {
|
||||
refreshToken
|
||||
clientId,
|
||||
clientSecret
|
||||
}
|
||||
} = await validateRequest(reqValidator.RefreshTokenV3, req);
|
||||
} = await validateRequest(reqValidator.LoginMachineIdentityV3, req);
|
||||
|
||||
const decodedToken = <jwt.MachineRefreshTokenJwtPayload>(
|
||||
jwt.verify(refreshToken, await getAuthSecret())
|
||||
);
|
||||
|
||||
if (decodedToken.authTokenType !== AuthTokenType.MACHINE_REFRESH_TOKEN) throw UnauthorizedRequestError();
|
||||
|
||||
let machineIdentity = await MachineIdentity.findOne({
|
||||
_id: new Types.ObjectId(decodedToken._id),
|
||||
const machineIdentity = await MachineIdentity.findOne({
|
||||
clientId,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!machineIdentity) throw UnauthorizedRequestError();
|
||||
|
||||
checkIPAgainstBlocklist({
|
||||
ipAddress: req.realIP,
|
||||
trustedIps: machineIdentity.clientSecretTrustedIps
|
||||
});
|
||||
|
||||
if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) {
|
||||
// raise alarm
|
||||
throw UnauthorizedRequestError();
|
||||
const clientSecretData = await MachineIdentityClientSecretData.find({
|
||||
machineIdentity: machineIdentity._id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
let validatedClientSecretDatum: IMachineIdentityClientSecretData | undefined;
|
||||
|
||||
for (const clientSecretDatum of clientSecretData) {
|
||||
const isSecretValid = await bcrypt.compare(
|
||||
clientSecret,
|
||||
clientSecretDatum.clientSecretHash
|
||||
);
|
||||
|
||||
if (isSecretValid) {
|
||||
validatedClientSecretDatum = clientSecretDatum;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const response: {
|
||||
refreshToken?: string;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
} = {
|
||||
refreshToken,
|
||||
accessToken: "",
|
||||
expiresIn: 0,
|
||||
tokenType: "Bearer"
|
||||
};
|
||||
|
||||
if (machineIdentity.isRefreshTokenRotationEnabled) {
|
||||
machineIdentity = await MachineIdentity.findByIdAndUpdate(
|
||||
machineIdentity._id,
|
||||
if (!validatedClientSecretDatum) throw UnauthorizedRequestError();
|
||||
|
||||
const {
|
||||
expiresAt,
|
||||
clientSecretUsageCount,
|
||||
clientSecretUsageLimit
|
||||
} = validatedClientSecretDatum;
|
||||
|
||||
if (expiresAt && new Date(expiresAt) < new Date()) {
|
||||
// client secret expired
|
||||
await MachineIdentityClientSecretData.findByIdAndUpdate(
|
||||
validatedClientSecretDatum._id,
|
||||
{
|
||||
$inc: {
|
||||
tokenVersion: 1
|
||||
}
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
if (!machineIdentity) throw BadRequestError();
|
||||
|
||||
response.refreshToken = createToken({
|
||||
payload: {
|
||||
serviceTokenDataId: machineIdentity._id.toString(),
|
||||
authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN,
|
||||
tokenVersion: machineIdentity.tokenVersion
|
||||
|
||||
throw UnauthorizedRequestError();
|
||||
}
|
||||
|
||||
if (clientSecretUsageLimit > 0 && clientSecretUsageCount === clientSecretUsageLimit) {
|
||||
// number of times client secret can be used for
|
||||
// a login operation reached
|
||||
await MachineIdentityClientSecretData.findByIdAndUpdate(
|
||||
validatedClientSecretDatum._id,
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
secret: await getAuthSecret()
|
||||
});
|
||||
{
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
throw UnauthorizedRequestError();
|
||||
}
|
||||
|
||||
response.accessToken = createToken({
|
||||
payload: {
|
||||
_id: machineIdentity._id.toString(),
|
||||
authTokenType: AuthTokenType.MACHINE_ACCESS_TOKEN,
|
||||
tokenVersion: machineIdentity.tokenVersion
|
||||
},
|
||||
expiresIn: machineIdentity.accessTokenTTL,
|
||||
secret: await getAuthSecret()
|
||||
});
|
||||
|
||||
response.expiresIn = machineIdentity.accessTokenTTL;
|
||||
|
||||
await MachineIdentity.findByIdAndUpdate(
|
||||
machineIdentity._id,
|
||||
// increment usage count by 1
|
||||
await MachineIdentityClientSecretData.findByIdAndUpdate(
|
||||
validatedClientSecretDatum._id,
|
||||
{
|
||||
refreshTokenLastUsed: new Date(),
|
||||
$inc: { refreshTokenUsageCount: 1 }
|
||||
$inc: { clientSecretUsageCount: 1 }
|
||||
},
|
||||
{
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(200).send(response);
|
||||
|
||||
// token version
|
||||
const accessToken = createToken({
|
||||
payload: {
|
||||
machineId: machineIdentity._id.toString(), // consider changing to clientId and making it more extensible
|
||||
clientSecretDataId: validatedClientSecretDatum._id.toString(),
|
||||
authTokenType: AuthTokenType.MACHINE_ACCESS_TOKEN,
|
||||
tokenVersion: validatedClientSecretDatum.accessTokenVersion
|
||||
},
|
||||
expiresIn: machineIdentity.accessTokenTTL,
|
||||
secret: await getAuthSecret()
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
accessToken,
|
||||
expiresIn: machineIdentity.accessTokenTTL,
|
||||
tokenType: "Bearer"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,10 +325,9 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
|
||||
name,
|
||||
organizationId,
|
||||
role,
|
||||
trustedIps,
|
||||
expiresIn,
|
||||
clientSecretTrustedIps,
|
||||
accessTokenTrustedIps,
|
||||
accessTokenTTL,
|
||||
isRefreshTokenRotationEnabled
|
||||
}
|
||||
} = await validateRequest(reqValidator.CreateMachineIdentityV3, req);
|
||||
|
||||
@@ -177,44 +364,44 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
|
||||
const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId));
|
||||
|
||||
// validate trusted ips
|
||||
const reformattedTrustedIps = trustedIps.map((trustedIp) => {
|
||||
if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => {
|
||||
if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
|
||||
const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress);
|
||||
const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress);
|
||||
|
||||
if (!isValidIPOrCidr) return res.status(400).send({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
|
||||
return extractIPDetails(trustedIp.ipAddress);
|
||||
return extractIPDetails(clientSecretTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
|
||||
if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
|
||||
const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress);
|
||||
|
||||
if (!isValidIPOrCidr) return res.status(400).send({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
let expiresAt;
|
||||
if (expiresIn) {
|
||||
expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
||||
}
|
||||
|
||||
let user;
|
||||
if (req.authData.actor.type === ActorType.USER) {
|
||||
user = req.authData.authPayload._id;
|
||||
}
|
||||
|
||||
const isActive = true;
|
||||
const machineIdentity = await new MachineIdentity({
|
||||
clientId: crypto.randomUUID(),
|
||||
name,
|
||||
user,
|
||||
organization: new Types.ObjectId(organizationId),
|
||||
refreshTokenUsageCount: 0,
|
||||
accessTokenUsageCount: 0,
|
||||
tokenVersion: 1,
|
||||
trustedIps: reformattedTrustedIps,
|
||||
isActive,
|
||||
expiresAt,
|
||||
accessTokenTTL,
|
||||
isRefreshTokenRotationEnabled
|
||||
accessTokenUsageCount: 0,
|
||||
clientSecretTrustedIps: reformattedClientSecretTrustedIps,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps,
|
||||
}).save();
|
||||
|
||||
await new MachineMembershipOrg({
|
||||
@@ -224,15 +411,6 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
|
||||
customRole
|
||||
}).save();
|
||||
|
||||
const refreshToken = createToken({
|
||||
payload: {
|
||||
_id: machineIdentity._id.toString(),
|
||||
authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN,
|
||||
tokenVersion: machineIdentity.tokenVersion
|
||||
},
|
||||
secret: await getAuthSecret()
|
||||
});
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
req.authData,
|
||||
{
|
||||
@@ -241,8 +419,8 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
|
||||
name,
|
||||
isActive,
|
||||
role,
|
||||
trustedIps: reformattedTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
expiresAt
|
||||
clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array<IMachineIdentityTrustedIp>
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -251,8 +429,7 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
|
||||
);
|
||||
|
||||
return res.status(200).send({
|
||||
machineIdentity,
|
||||
refreshToken
|
||||
machineIdentity
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,10 +445,9 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
|
||||
body: {
|
||||
name,
|
||||
role,
|
||||
trustedIps,
|
||||
expiresIn,
|
||||
accessTokenTTL,
|
||||
isRefreshTokenRotationEnabled
|
||||
clientSecretTrustedIps,
|
||||
accessTokenTrustedIps,
|
||||
accessTokenTTL
|
||||
}
|
||||
} = await validateRequest(reqValidator.UpdateMachineIdentityV3, req);
|
||||
|
||||
@@ -312,38 +488,49 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
|
||||
|
||||
const plan = await EELicenseService.getPlan(machineIdentity.organization);
|
||||
|
||||
// validate trusted ips
|
||||
let reformattedTrustedIps;
|
||||
if (trustedIps) {
|
||||
reformattedTrustedIps = trustedIps.map((trustedIp) => {
|
||||
if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
// validate client secret trusted ips
|
||||
let reformattedClientSecretTrustedIps;
|
||||
if (clientSecretTrustedIps) {
|
||||
reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => {
|
||||
if (!plan.ipAllowlisting && clientSecretTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range."
|
||||
});
|
||||
|
||||
const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress);
|
||||
const isValidIPOrCidr = isValidIpOrCidr(clientSecretTrustedIp.ipAddress);
|
||||
|
||||
if (!isValidIPOrCidr) return res.status(400).send({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
|
||||
return extractIPDetails(trustedIp.ipAddress);
|
||||
return extractIPDetails(clientSecretTrustedIp.ipAddress);
|
||||
});
|
||||
}
|
||||
|
||||
let expiresAt;
|
||||
if (expiresIn) {
|
||||
expiresAt = new Date();
|
||||
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
||||
// validate access token trusted ips
|
||||
let reformattedAccessTokenTrustedIps;
|
||||
if (accessTokenTrustedIps) {
|
||||
reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
|
||||
if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
|
||||
message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range."
|
||||
});
|
||||
|
||||
const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress);
|
||||
|
||||
if (!isValidIPOrCidr) return res.status(400).send({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
}
|
||||
|
||||
machineIdentity = await MachineIdentity.findByIdAndUpdate(
|
||||
machineId,
|
||||
{
|
||||
name,
|
||||
trustedIps: reformattedTrustedIps,
|
||||
expiresAt,
|
||||
accessTokenTTL,
|
||||
isRefreshTokenRotationEnabled
|
||||
clientSecretTrustedIps: reformattedClientSecretTrustedIps,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps,
|
||||
accessTokenTTL
|
||||
},
|
||||
{
|
||||
new: true
|
||||
@@ -381,8 +568,8 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
|
||||
metadata: {
|
||||
name: machineIdentity.name,
|
||||
role,
|
||||
trustedIps: reformattedTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
expiresAt
|
||||
clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array<IMachineIdentityTrustedIp>
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -436,6 +623,10 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => {
|
||||
machineIdentity: machineIdentity._id,
|
||||
});
|
||||
|
||||
await MachineIdentityClientSecretData.deleteMany({
|
||||
machineIdentity: machineIdentity._id
|
||||
});
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
req.authData,
|
||||
{
|
||||
@@ -444,8 +635,8 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => {
|
||||
name: machineIdentity.name,
|
||||
isActive: machineIdentity.isActive,
|
||||
role: machineMembershipOrg.role,
|
||||
trustedIps: machineIdentity.trustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
expiresAt: machineIdentity.expiresAt
|
||||
clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps as Array<IMachineIdentityTrustedIp>,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -231,8 +231,8 @@ interface CreateMachineIdentityEvent {
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
role: string;
|
||||
trustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
expiresAt?: Date;
|
||||
clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,8 +241,8 @@ interface UpdateMachineIdentityEvent {
|
||||
metadata: {
|
||||
name?: string;
|
||||
role?: string;
|
||||
trustedIps?: Array<IMachineIdentityTrustedIp>;
|
||||
expiresAt?: Date;
|
||||
clientSecretTrustedIps?: Array<IMachineIdentityTrustedIp>;
|
||||
accessTokenTrustedIps?: Array<IMachineIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,8 +252,8 @@ interface DeleteMachineIdentityEvent {
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
role: string;
|
||||
expiresAt?: Date;
|
||||
trustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,34 @@ import { requireAuth } from "../../../middleware";
|
||||
import { AuthMode } from "../../../variables";
|
||||
import { machineIdentityController } from "../../controllers/v3";
|
||||
|
||||
router.get(
|
||||
"/:machineId/client-secrets",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
machineIdentityController.getMIClientSecrets
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/me/token",
|
||||
machineIdentityController.refreshToken
|
||||
"/:machineId/client-secrets",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
machineIdentityController.createMIClientSecret
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:machineId/client-secrets/:clientSecretId",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
machineIdentityController.deleteMIClientSecret
|
||||
);
|
||||
|
||||
// consider moving to /auth/app/login
|
||||
router.post(
|
||||
"/login",
|
||||
machineIdentityController.loginMI
|
||||
);
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -69,7 +69,7 @@ class EELicenseService {
|
||||
rbac: false,
|
||||
customRateLimits: false,
|
||||
customAlerts: false,
|
||||
auditLogs: false,
|
||||
auditLogs: true,
|
||||
auditLogsRetentionDays: 0,
|
||||
samlSSO: false,
|
||||
status: null,
|
||||
|
||||
@@ -327,7 +327,7 @@ export const getAuthDataProjectPermissions = async ({
|
||||
|
||||
checkIPAgainstBlocklist({
|
||||
ipAddress: authData.ipAddress,
|
||||
trustedIps: machineMembership.machineIdentity.trustedIps
|
||||
trustedIps: machineMembership.machineIdentity.accessTokenTrustedIps
|
||||
});
|
||||
|
||||
role = machineMembership.role;
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from "./userAction";
|
||||
export * from "./workspace";
|
||||
export * from "./serviceTokenData"; // TODO: deprecate
|
||||
export * from "./machineIdentity";
|
||||
export * from "./machineIdentityClientSecretData";
|
||||
export * from "./machineMembershipOrg";
|
||||
export * from "./machineMembership";
|
||||
export * from "./apiKeyData"; // TODO: deprecate
|
||||
|
||||
@@ -7,25 +7,27 @@ export interface IMachineIdentityTrustedIp {
|
||||
prefix: number;
|
||||
}
|
||||
|
||||
// TODO: rename to AppClient
|
||||
|
||||
export interface IMachineIdentity extends Document {
|
||||
_id: Types.ObjectId;
|
||||
clientId: string;
|
||||
name: string;
|
||||
organization: Types.ObjectId;
|
||||
user: Types.ObjectId;
|
||||
isActive: boolean;
|
||||
refreshTokenLastUsed?: Date;
|
||||
accessTokenLastUsed?: Date;
|
||||
refreshTokenUsageCount: number;
|
||||
accessTokenUsageCount: number;
|
||||
tokenVersion: number;
|
||||
isRefreshTokenRotationEnabled: boolean;
|
||||
expiresAt?: Date;
|
||||
accessTokenTTL: number;
|
||||
trustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
accessTokenLastUsed?: Date;
|
||||
accessTokenUsageCount: number;
|
||||
clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
|
||||
}
|
||||
|
||||
const machineIdentitySchema = new Schema(
|
||||
{
|
||||
clientId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -35,55 +37,54 @@ const machineIdentitySchema = new Schema(
|
||||
ref: "Organization",
|
||||
required: true
|
||||
},
|
||||
user: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
required: true
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: true
|
||||
},
|
||||
refreshTokenLastUsed: {
|
||||
type: Date,
|
||||
required: false
|
||||
},
|
||||
accessTokenLastUsed: {
|
||||
type: Date,
|
||||
required: false
|
||||
},
|
||||
refreshTokenUsageCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: true
|
||||
},
|
||||
accessTokenUsageCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: true
|
||||
},
|
||||
tokenVersion: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
required: true
|
||||
},
|
||||
isRefreshTokenRotationEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: true
|
||||
},
|
||||
expiresAt: { // consider revising field name
|
||||
type: Date,
|
||||
required: false,
|
||||
// expires: 0
|
||||
},
|
||||
accessTokenTTL: { // seconds
|
||||
type: Number,
|
||||
default: 7200,
|
||||
required: true
|
||||
},
|
||||
trustedIps: {
|
||||
accessTokenLastUsed: {
|
||||
type: Date,
|
||||
required: false
|
||||
},
|
||||
accessTokenUsageCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: true
|
||||
},
|
||||
clientSecretTrustedIps: {
|
||||
type: [
|
||||
{
|
||||
ipAddress: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: [
|
||||
IPType.IPV4,
|
||||
IPType.IPV6
|
||||
],
|
||||
required: true
|
||||
},
|
||||
prefix: {
|
||||
type: Number,
|
||||
required: false
|
||||
}
|
||||
}
|
||||
],
|
||||
default: [{
|
||||
ipAddress: "0.0.0.0",
|
||||
type: IPType.IPV4.toString(),
|
||||
prefix: 0
|
||||
}],
|
||||
required: true
|
||||
},
|
||||
accessTokenTrustedIps: {
|
||||
type: [
|
||||
{
|
||||
ipAddress: {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Document, Schema, Types, model } from "mongoose";
|
||||
|
||||
export interface IMachineIdentityClientSecretData extends Document {
|
||||
_id: Types.ObjectId;
|
||||
machineIdentity: Types.ObjectId;
|
||||
isActive: boolean;
|
||||
description: string;
|
||||
clientSecretPrefix: string;
|
||||
clientSecretHash: string;
|
||||
clientSecretLastUsed?: Date;
|
||||
clientSecretUsageCount: number;
|
||||
clientSecretUsageLimit: number;
|
||||
accessTokenVersion: number;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
const machineIdentityClientSecretDataSchema = new Schema(
|
||||
{
|
||||
machineIdentity: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "MachineIdentity",
|
||||
required: true
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clientSecretPrefix: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clientSecretHash: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clientSecretLastUsed: {
|
||||
type: Date,
|
||||
required: false
|
||||
},
|
||||
clientSecretUsageCount: {
|
||||
// number of times client secret has been used
|
||||
// in login operation
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: true
|
||||
},
|
||||
clientSecretUsageLimit: {
|
||||
// number of times client secret can be used for
|
||||
// a login operation
|
||||
type: Number,
|
||||
default: 0, // default: used as many times as needed
|
||||
required: true
|
||||
},
|
||||
accessTokenVersion: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
required: true
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
export const MachineIdentityClientSecretData = model<IMachineIdentityClientSecretData>("MachineIdentityClientSecretData", machineIdentityClientSecretDataSchema);
|
||||
@@ -1,6 +1,6 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Types } from "mongoose";
|
||||
import { MachineIdentity } from "../../../models";
|
||||
import { MachineIdentity, MachineIdentityClientSecretData } from "../../../models";
|
||||
import { getAuthSecret } from "../../../config";
|
||||
import { AuthTokenType } from "../../../variables";
|
||||
import { UnauthorizedRequestError } from "../../errors";
|
||||
@@ -12,45 +12,28 @@ interface ValidateMachineIdentityParams {
|
||||
export const validateMachineIdentity = async ({
|
||||
authTokenValue
|
||||
}: ValidateMachineIdentityParams) => {
|
||||
const decodedToken = <jwt.MachineRefreshTokenJwtPayload>(
|
||||
const decodedToken = <jwt.MachineAccessTokenJwtPayload>(
|
||||
jwt.verify(authTokenValue, await getAuthSecret())
|
||||
);
|
||||
|
||||
if (decodedToken.authTokenType !== AuthTokenType.MACHINE_ACCESS_TOKEN) throw UnauthorizedRequestError();
|
||||
|
||||
const machineIdentity = await MachineIdentity.findOne({
|
||||
_id: new Types.ObjectId(decodedToken._id),
|
||||
const machineIdentityClientSecretData = await MachineIdentityClientSecretData.findOne({
|
||||
_id: new Types.ObjectId(decodedToken.clientSecretDataId),
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!machineIdentity) {
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed to authenticate"
|
||||
});
|
||||
} else if (machineIdentity?.expiresAt && new Date(machineIdentity.expiresAt) < new Date()) {
|
||||
// case: service token expired
|
||||
await MachineIdentity.findByIdAndUpdate(
|
||||
machineIdentity._id,
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed to authenticate",
|
||||
});
|
||||
} else if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) {
|
||||
if (!machineIdentityClientSecretData) throw UnauthorizedRequestError();
|
||||
|
||||
if (decodedToken.tokenVersion !== machineIdentityClientSecretData.accessTokenVersion) {
|
||||
// TODO: raise alarm
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed to authenticate",
|
||||
});
|
||||
}
|
||||
|
||||
await MachineIdentity.findByIdAndUpdate(
|
||||
machineIdentity._id,
|
||||
const machineIdentity = await MachineIdentity.findByIdAndUpdate(
|
||||
machineIdentityClientSecretData.machineIdentity,
|
||||
{
|
||||
accessTokenLastUsed: new Date(),
|
||||
$inc: { accessTokenUsageCount: 1 }
|
||||
@@ -59,6 +42,10 @@ export const validateMachineIdentity = async ({
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
if (!machineIdentity) throw UnauthorizedRequestError({
|
||||
message: "Failed to authenticate"
|
||||
});
|
||||
|
||||
return machineIdentity;
|
||||
}
|
||||
@@ -39,6 +39,6 @@ export const getAuthDataPayloadUserObj = (authData: AuthData) => {
|
||||
}
|
||||
|
||||
if (authData.authPayload instanceof MachineIdentity) {
|
||||
return { user: authData.authPayload.user };
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import { NO_ACCESS } from "../variables";
|
||||
|
||||
export const RefreshTokenV3 = z.object({
|
||||
export const GetClientSecretsV3 = z.object({
|
||||
params: z.object({
|
||||
machineId: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
export const CreateClientSecretV3 = z.object({
|
||||
params: z.object({
|
||||
machineId: z.string()
|
||||
}),
|
||||
body: z.object({
|
||||
refreshToken: z.string().trim()
|
||||
description: z.string().trim().default(""),
|
||||
usageLimit: z.number().min(0).default(0),
|
||||
ttl: z.number().min(0).default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
export const DeleteClientSecretV3 = z.object({
|
||||
params: z.object({
|
||||
machineId: z.string(),
|
||||
clientSecretId: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
export const LoginMachineIdentityV3 = z.object({
|
||||
body: z.object({
|
||||
clientId: z.string().trim(),
|
||||
clientSecret: z.string().trim()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -12,16 +37,21 @@ export const CreateMachineIdentityV3 = z.object({
|
||||
name: z.string().trim(),
|
||||
organizationId: z.string().trim(),
|
||||
role: z.string().trim().min(1).default(NO_ACCESS),
|
||||
trustedIps: z
|
||||
clientSecretTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim(),
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.default([{ ipAddress: "0.0.0.0/0" }]),
|
||||
expiresIn: z.number().optional(),
|
||||
accessTokenTTL: z.number().int().min(1),
|
||||
isRefreshTokenRotationEnabled: z.boolean().default(false)
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim(),
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.default([{ ipAddress: "0.0.0.0/0" }]),
|
||||
accessTokenTTL: z.number().int().min(1)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -32,16 +62,21 @@ export const UpdateMachineIdentityV3 = z.object({
|
||||
body: z.object({
|
||||
name: z.string().trim().optional(),
|
||||
role: z.string().trim().min(1).optional(),
|
||||
trustedIps: z
|
||||
clientSecretTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
expiresIn: z.number().optional(),
|
||||
accessTokenTTL: z.number().int().min(1).optional(),
|
||||
isRefreshTokenRotationEnabled: z.boolean().optional()
|
||||
accessTokenTrustedIps: z
|
||||
.object({
|
||||
ipAddress: z.string().trim(),
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
accessTokenTTL: z.number().int().min(1).optional()
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user