Move MI from refresh token to client id / client secrets approach

This commit is contained in:
Tuan Dang
2023-12-04 16:13:00 +07:00
parent 6557d7668e
commit 69dae1f0b2
25 changed files with 1307 additions and 543 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ declare module "jsonwebtoken" {
userId: string; userId: string;
refreshVersion?: number; refreshVersion?: number;
} }
export interface MachineRefreshTokenJwtPayload extends jwt.JwtPayload { export interface MachineAccessTokenJwtPayload extends jwt.JwtPayload {
_id: string; _id: string;
authTokenType: string; authTokenType: string;
tokenVersion: number; tokenVersion: number;
@@ -1,15 +1,17 @@
import jwt from "jsonwebtoken"; import bcrypt from "bcrypt";
import crypto from "crypto";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { import {
IMachineIdentityClientSecretData,
IMachineIdentityTrustedIp, IMachineIdentityTrustedIp,
MachineIdentity, MachineIdentity,
MachineIdentityClientSecretData,
MachineMembership, MachineMembership,
MachineMembershipOrg, MachineMembershipOrg,
Organization, Organization,
} from "../../../models"; } from "../../../models";
import { import {
ActorType,
EventType, EventType,
Role Role
} from "../../models"; } from "../../models";
@@ -24,105 +26,291 @@ import {
import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors";
import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip";
import { EEAuditLogService, EELicenseService } from "../../services"; import { EEAuditLogService, EELicenseService } from "../../services";
import { getAuthSecret } from "../../../config"; import { getAuthSecret, getSaltRounds } from "../../../config";
import { ADMIN, AuthTokenType, CUSTOM, MEMBER, NO_ACCESS } from "../../../variables"; import { ADMIN, AuthTokenType, CUSTOM, MEMBER, NO_ACCESS } from "../../../variables";
import { import {
OrgPermissionActions, OrgPermissionActions,
OrgPermissionSubjects OrgPermissionSubjects
} from "../../services/RoleService"; } from "../../services/RoleService";
import { ForbiddenError } from "@casl/ability"; 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 req
* @param res * @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 { const {
body: { body: {
refreshToken clientId,
clientSecret
} }
} = await validateRequest(reqValidator.RefreshTokenV3, req); } = await validateRequest(reqValidator.LoginMachineIdentityV3, req);
const decodedToken = <jwt.MachineRefreshTokenJwtPayload>( const machineIdentity = await MachineIdentity.findOne({
jwt.verify(refreshToken, await getAuthSecret()) clientId,
);
if (decodedToken.authTokenType !== AuthTokenType.MACHINE_REFRESH_TOKEN) throw UnauthorizedRequestError();
let machineIdentity = await MachineIdentity.findOne({
_id: new Types.ObjectId(decodedToken._id),
isActive: true isActive: true
}); });
if (!machineIdentity) throw UnauthorizedRequestError(); if (!machineIdentity) throw UnauthorizedRequestError();
if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) { checkIPAgainstBlocklist({
// raise alarm ipAddress: req.realIP,
throw UnauthorizedRequestError(); trustedIps: machineIdentity.clientSecretTrustedIps
});
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: { if (!validatedClientSecretDatum) throw UnauthorizedRequestError();
refreshToken?: string;
accessToken: string;
expiresIn: number;
tokenType: string;
} = {
refreshToken,
accessToken: "",
expiresIn: 0,
tokenType: "Bearer"
};
if (machineIdentity.isRefreshTokenRotationEnabled) { const {
machineIdentity = await MachineIdentity.findByIdAndUpdate( expiresAt,
machineIdentity._id, clientSecretUsageCount,
clientSecretUsageLimit
} = validatedClientSecretDatum;
if (expiresAt && new Date(expiresAt) < new Date()) {
// client secret expired
await MachineIdentityClientSecretData.findByIdAndUpdate(
validatedClientSecretDatum._id,
{ {
$inc: { isActive: false
tokenVersion: 1
}
}, },
{ {
new: true new: true
} }
); );
if (!machineIdentity) throw BadRequestError(); throw UnauthorizedRequestError();
response.refreshToken = createToken({
payload: {
serviceTokenDataId: machineIdentity._id.toString(),
authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN,
tokenVersion: machineIdentity.tokenVersion
},
secret: await getAuthSecret()
});
} }
response.accessToken = createToken({ if (clientSecretUsageLimit > 0 && clientSecretUsageCount === clientSecretUsageLimit) {
payload: { // number of times client secret can be used for
_id: machineIdentity._id.toString(), // a login operation reached
authTokenType: AuthTokenType.MACHINE_ACCESS_TOKEN, await MachineIdentityClientSecretData.findByIdAndUpdate(
tokenVersion: machineIdentity.tokenVersion validatedClientSecretDatum._id,
}, {
expiresIn: machineIdentity.accessTokenTTL, isActive: false
secret: await getAuthSecret() },
}); {
new: true
}
);
response.expiresIn = machineIdentity.accessTokenTTL; throw UnauthorizedRequestError();
}
await MachineIdentity.findByIdAndUpdate( // increment usage count by 1
machineIdentity._id, await MachineIdentityClientSecretData.findByIdAndUpdate(
validatedClientSecretDatum._id,
{ {
refreshTokenLastUsed: new Date(), $inc: { clientSecretUsageCount: 1 }
$inc: { refreshTokenUsageCount: 1 }
}, },
{ {
new: true 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, name,
organizationId, organizationId,
role, role,
trustedIps, clientSecretTrustedIps,
expiresIn, accessTokenTrustedIps,
accessTokenTTL, accessTokenTTL,
isRefreshTokenRotationEnabled
} }
} = await validateRequest(reqValidator.CreateMachineIdentityV3, req); } = 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)); const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId));
// validate trusted ips // validate trusted ips
const reformattedTrustedIps = trustedIps.map((trustedIp) => { const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => {
if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ 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." 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({ if (!isValidIPOrCidr) return res.status(400).send({
message: "The IP is not a valid IPv4, IPv6, or CIDR block" message: "The IP is not a valid IPv4, IPv6, or CIDR block"
}); });
return extractIPDetails(trustedIp.ipAddress); return extractIPDetails(clientSecretTrustedIp.ipAddress);
}); });
let expiresAt; const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
if (expiresIn) { if (!plan.ipAllowlisting && accessTokenTrustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({
expiresAt = new Date(); message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); });
}
let user; const isValidIPOrCidr = isValidIpOrCidr(accessTokenTrustedIp.ipAddress);
if (req.authData.actor.type === ActorType.USER) {
user = req.authData.authPayload._id; if (!isValidIPOrCidr) return res.status(400).send({
} message: "The IP is not a valid IPv4, IPv6, or CIDR block"
});
return extractIPDetails(accessTokenTrustedIp.ipAddress);
});
const isActive = true; const isActive = true;
const machineIdentity = await new MachineIdentity({ const machineIdentity = await new MachineIdentity({
clientId: crypto.randomUUID(),
name, name,
user,
organization: new Types.ObjectId(organizationId), organization: new Types.ObjectId(organizationId),
refreshTokenUsageCount: 0,
accessTokenUsageCount: 0,
tokenVersion: 1,
trustedIps: reformattedTrustedIps,
isActive, isActive,
expiresAt,
accessTokenTTL, accessTokenTTL,
isRefreshTokenRotationEnabled accessTokenUsageCount: 0,
clientSecretTrustedIps: reformattedClientSecretTrustedIps,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps,
}).save(); }).save();
await new MachineMembershipOrg({ await new MachineMembershipOrg({
@@ -224,15 +411,6 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
customRole customRole
}).save(); }).save();
const refreshToken = createToken({
payload: {
_id: machineIdentity._id.toString(),
authTokenType: AuthTokenType.MACHINE_REFRESH_TOKEN,
tokenVersion: machineIdentity.tokenVersion
},
secret: await getAuthSecret()
});
await EEAuditLogService.createAuditLog( await EEAuditLogService.createAuditLog(
req.authData, req.authData,
{ {
@@ -241,8 +419,8 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
name, name,
isActive, isActive,
role, role,
trustedIps: reformattedTrustedIps as Array<IMachineIdentityTrustedIp>, clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
expiresAt accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array<IMachineIdentityTrustedIp>
} }
}, },
{ {
@@ -251,8 +429,7 @@ export const createMachineIdentity = async (req: Request, res: Response) => {
); );
return res.status(200).send({ return res.status(200).send({
machineIdentity, machineIdentity
refreshToken
}); });
} }
@@ -268,10 +445,9 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
body: { body: {
name, name,
role, role,
trustedIps, clientSecretTrustedIps,
expiresIn, accessTokenTrustedIps,
accessTokenTTL, accessTokenTTL
isRefreshTokenRotationEnabled
} }
} = await validateRequest(reqValidator.UpdateMachineIdentityV3, req); } = await validateRequest(reqValidator.UpdateMachineIdentityV3, req);
@@ -312,38 +488,49 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
const plan = await EELicenseService.getPlan(machineIdentity.organization); const plan = await EELicenseService.getPlan(machineIdentity.organization);
// validate trusted ips // validate client secret trusted ips
let reformattedTrustedIps; let reformattedClientSecretTrustedIps;
if (trustedIps) { if (clientSecretTrustedIps) {
reformattedTrustedIps = trustedIps.map((trustedIp) => { reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => {
if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ 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." 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({ if (!isValidIPOrCidr) return res.status(400).send({
message: "The IP is not a valid IPv4, IPv6, or CIDR block" message: "The IP is not a valid IPv4, IPv6, or CIDR block"
}); });
return extractIPDetails(trustedIp.ipAddress); return extractIPDetails(clientSecretTrustedIp.ipAddress);
}); });
} }
let expiresAt; // validate access token trusted ips
if (expiresIn) { let reformattedAccessTokenTrustedIps;
expiresAt = new Date(); if (accessTokenTrustedIps) {
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); 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( machineIdentity = await MachineIdentity.findByIdAndUpdate(
machineId, machineId,
{ {
name, name,
trustedIps: reformattedTrustedIps, clientSecretTrustedIps: reformattedClientSecretTrustedIps,
expiresAt, accessTokenTrustedIps: reformattedAccessTokenTrustedIps,
accessTokenTTL, accessTokenTTL
isRefreshTokenRotationEnabled
}, },
{ {
new: true new: true
@@ -381,8 +568,8 @@ export const updateMachineIdentity = async (req: Request, res: Response) => {
metadata: { metadata: {
name: machineIdentity.name, name: machineIdentity.name,
role, role,
trustedIps: reformattedTrustedIps as Array<IMachineIdentityTrustedIp>, clientSecretTrustedIps: reformattedClientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
expiresAt accessTokenTrustedIps: reformattedAccessTokenTrustedIps as Array<IMachineIdentityTrustedIp>
} }
}, },
{ {
@@ -436,6 +623,10 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => {
machineIdentity: machineIdentity._id, machineIdentity: machineIdentity._id,
}); });
await MachineIdentityClientSecretData.deleteMany({
machineIdentity: machineIdentity._id
});
await EEAuditLogService.createAuditLog( await EEAuditLogService.createAuditLog(
req.authData, req.authData,
{ {
@@ -444,8 +635,8 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => {
name: machineIdentity.name, name: machineIdentity.name,
isActive: machineIdentity.isActive, isActive: machineIdentity.isActive,
role: machineMembershipOrg.role, role: machineMembershipOrg.role,
trustedIps: machineIdentity.trustedIps as Array<IMachineIdentityTrustedIp>, clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps as Array<IMachineIdentityTrustedIp>,
expiresAt: machineIdentity.expiresAt accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps as Array<IMachineIdentityTrustedIp>,
} }
}, },
{ {
+6 -6
View File
@@ -231,8 +231,8 @@ interface CreateMachineIdentityEvent {
name: string; name: string;
isActive: boolean; isActive: boolean;
role: string; role: string;
trustedIps: Array<IMachineIdentityTrustedIp>; clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
expiresAt?: Date; accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
}; };
} }
@@ -241,8 +241,8 @@ interface UpdateMachineIdentityEvent {
metadata: { metadata: {
name?: string; name?: string;
role?: string; role?: string;
trustedIps?: Array<IMachineIdentityTrustedIp>; clientSecretTrustedIps?: Array<IMachineIdentityTrustedIp>;
expiresAt?: Date; accessTokenTrustedIps?: Array<IMachineIdentityTrustedIp>;
}; };
} }
@@ -252,8 +252,8 @@ interface DeleteMachineIdentityEvent {
name: string; name: string;
isActive: boolean; isActive: boolean;
role: string; role: string;
expiresAt?: Date; clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
trustedIps: Array<IMachineIdentityTrustedIp>; accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
}; };
} }
+27 -2
View File
@@ -4,9 +4,34 @@ import { requireAuth } from "../../../middleware";
import { AuthMode } from "../../../variables"; import { AuthMode } from "../../../variables";
import { machineIdentityController } from "../../controllers/v3"; import { machineIdentityController } from "../../controllers/v3";
router.get(
"/:machineId/client-secrets",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
machineIdentityController.getMIClientSecrets
);
router.post( router.post(
"/me/token", "/:machineId/client-secrets",
machineIdentityController.refreshToken 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( router.post(
+1 -1
View File
@@ -69,7 +69,7 @@ class EELicenseService {
rbac: false, rbac: false,
customRateLimits: false, customRateLimits: false,
customAlerts: false, customAlerts: false,
auditLogs: false, auditLogs: true,
auditLogsRetentionDays: 0, auditLogsRetentionDays: 0,
samlSSO: false, samlSSO: false,
status: null, status: null,
@@ -327,7 +327,7 @@ export const getAuthDataProjectPermissions = async ({
checkIPAgainstBlocklist({ checkIPAgainstBlocklist({
ipAddress: authData.ipAddress, ipAddress: authData.ipAddress,
trustedIps: machineMembership.machineIdentity.trustedIps trustedIps: machineMembership.machineIdentity.accessTokenTrustedIps
}); });
role = machineMembership.role; role = machineMembership.role;
+1
View File
@@ -21,6 +21,7 @@ export * from "./userAction";
export * from "./workspace"; export * from "./workspace";
export * from "./serviceTokenData"; // TODO: deprecate export * from "./serviceTokenData"; // TODO: deprecate
export * from "./machineIdentity"; export * from "./machineIdentity";
export * from "./machineIdentityClientSecretData";
export * from "./machineMembershipOrg"; export * from "./machineMembershipOrg";
export * from "./machineMembership"; export * from "./machineMembership";
export * from "./apiKeyData"; // TODO: deprecate export * from "./apiKeyData"; // TODO: deprecate
+49 -48
View File
@@ -7,25 +7,27 @@ export interface IMachineIdentityTrustedIp {
prefix: number; prefix: number;
} }
// TODO: rename to AppClient
export interface IMachineIdentity extends Document { export interface IMachineIdentity extends Document {
_id: Types.ObjectId; _id: Types.ObjectId;
clientId: string;
name: string; name: string;
organization: Types.ObjectId; organization: Types.ObjectId;
user: Types.ObjectId;
isActive: boolean; isActive: boolean;
refreshTokenLastUsed?: Date;
accessTokenLastUsed?: Date;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
tokenVersion: number;
isRefreshTokenRotationEnabled: boolean;
expiresAt?: Date;
accessTokenTTL: number; accessTokenTTL: number;
trustedIps: Array<IMachineIdentityTrustedIp>; accessTokenLastUsed?: Date;
accessTokenUsageCount: number;
clientSecretTrustedIps: Array<IMachineIdentityTrustedIp>;
accessTokenTrustedIps: Array<IMachineIdentityTrustedIp>;
} }
const machineIdentitySchema = new Schema( const machineIdentitySchema = new Schema(
{ {
clientId: {
type: String,
required: true
},
name: { name: {
type: String, type: String,
required: true required: true
@@ -35,55 +37,54 @@ const machineIdentitySchema = new Schema(
ref: "Organization", ref: "Organization",
required: true required: true
}, },
user: {
type: Schema.Types.ObjectId,
ref: "User",
required: true
},
isActive: { isActive: {
type: Boolean, type: Boolean,
default: true, default: true,
required: 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 accessTokenTTL: { // seconds
type: Number, type: Number,
default: 7200, default: 7200,
required: true 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: [ type: [
{ {
ipAddress: { 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 jwt from "jsonwebtoken";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { MachineIdentity } from "../../../models"; import { MachineIdentity, MachineIdentityClientSecretData } from "../../../models";
import { getAuthSecret } from "../../../config"; import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables"; import { AuthTokenType } from "../../../variables";
import { UnauthorizedRequestError } from "../../errors"; import { UnauthorizedRequestError } from "../../errors";
@@ -12,45 +12,28 @@ interface ValidateMachineIdentityParams {
export const validateMachineIdentity = async ({ export const validateMachineIdentity = async ({
authTokenValue authTokenValue
}: ValidateMachineIdentityParams) => { }: ValidateMachineIdentityParams) => {
const decodedToken = <jwt.MachineRefreshTokenJwtPayload>( const decodedToken = <jwt.MachineAccessTokenJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret()) jwt.verify(authTokenValue, await getAuthSecret())
); );
if (decodedToken.authTokenType !== AuthTokenType.MACHINE_ACCESS_TOKEN) throw UnauthorizedRequestError(); if (decodedToken.authTokenType !== AuthTokenType.MACHINE_ACCESS_TOKEN) throw UnauthorizedRequestError();
const machineIdentity = await MachineIdentity.findOne({ const machineIdentityClientSecretData = await MachineIdentityClientSecretData.findOne({
_id: new Types.ObjectId(decodedToken._id), _id: new Types.ObjectId(decodedToken.clientSecretDataId),
isActive: true isActive: true
}); });
if (!machineIdentity) { if (!machineIdentityClientSecretData) throw UnauthorizedRequestError();
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({ if (decodedToken.tokenVersion !== machineIdentityClientSecretData.accessTokenVersion) {
message: "Failed to authenticate",
});
} else if (decodedToken.tokenVersion !== machineIdentity.tokenVersion) {
// TODO: raise alarm // TODO: raise alarm
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: "Failed to authenticate", message: "Failed to authenticate",
}); });
} }
await MachineIdentity.findByIdAndUpdate( const machineIdentity = await MachineIdentity.findByIdAndUpdate(
machineIdentity._id, machineIdentityClientSecretData.machineIdentity,
{ {
accessTokenLastUsed: new Date(), accessTokenLastUsed: new Date(),
$inc: { accessTokenUsageCount: 1 } $inc: { accessTokenUsageCount: 1 }
@@ -60,5 +43,9 @@ export const validateMachineIdentity = async ({
} }
); );
if (!machineIdentity) throw UnauthorizedRequestError({
message: "Failed to authenticate"
});
return machineIdentity; return machineIdentity;
} }
@@ -39,6 +39,6 @@ export const getAuthDataPayloadUserObj = (authData: AuthData) => {
} }
if (authData.authPayload instanceof MachineIdentity) { if (authData.authPayload instanceof MachineIdentity) {
return { user: authData.authPayload.user }; return {};
} }
} }
+45 -10
View File
@@ -1,9 +1,34 @@
import { z } from "zod"; import { z } from "zod";
import { NO_ACCESS } from "../variables"; 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({ 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(), name: z.string().trim(),
organizationId: z.string().trim(), organizationId: z.string().trim(),
role: z.string().trim().min(1).default(NO_ACCESS), role: z.string().trim().min(1).default(NO_ACCESS),
trustedIps: z clientSecretTrustedIps: z
.object({ .object({
ipAddress: z.string().trim(), ipAddress: z.string().trim(),
}) })
.array() .array()
.min(1) .min(1)
.default([{ ipAddress: "0.0.0.0/0" }]), .default([{ ipAddress: "0.0.0.0/0" }]),
expiresIn: z.number().optional(), accessTokenTrustedIps: z
accessTokenTTL: z.number().int().min(1), .object({
isRefreshTokenRotationEnabled: z.boolean().default(false) 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({ body: z.object({
name: z.string().trim().optional(), name: z.string().trim().optional(),
role: z.string().trim().min(1).optional(), role: z.string().trim().min(1).optional(),
trustedIps: z clientSecretTrustedIps: z
.object({ .object({
ipAddress: z.string().trim() ipAddress: z.string().trim()
}) })
.array() .array()
.min(1) .min(1)
.optional(), .optional(),
expiresIn: z.number().optional(), accessTokenTrustedIps: z
accessTokenTTL: z.number().int().min(1).optional(), .object({
isRefreshTokenRotationEnabled: z.boolean().optional() ipAddress: z.string().trim(),
})
.array()
.min(1)
.optional(),
accessTokenTTL: z.number().int().min(1).optional()
}), }),
}); });
@@ -1,4 +1,9 @@
export { export {
useCreateMachineIdentity, useCreateMachineIdentity,
useCreateMachineIdentityClientSecret,
useDeleteMachineIdentity, useDeleteMachineIdentity,
useDeleteMachineIdentityClientSecret,
useUpdateMachineIdentity} from "./mutations"; useUpdateMachineIdentity} from "./mutations";
export {
useGetMachineIdentityClientSecrets
} from "./queries";
@@ -3,12 +3,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries"; import { organizationKeys } from "../organization/queries";
import { machineIdentityKeys } from "./queries";
import { import {
CreateMachineIdentityClientSecretDTO,
CreateMachineIdentityClientSecretRes,
CreateMachineIdentityDTO, CreateMachineIdentityDTO,
CreateMachineIdentityRes, CreateMachineIdentityRes,
DeleteMachineIdentityDTO, DeleteMachineIdentityDTO,
MachineIdentity, MachineIdentity,
UpdateMachineIdentityDTO} from "./types"; UpdateMachineIdentityDTO,
} from "./types";
export const useCreateMachineIdentity = () => { export const useCreateMachineIdentity = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -17,12 +21,56 @@ export const useCreateMachineIdentity = () => {
const { data } = await apiRequest.post("/api/v3/machines/", body); const { data } = await apiRequest.post("/api/v3/machines/", body);
return data; return data;
}, },
onSuccess: ({ machineIdentity }) => { onSuccess: ({ machineIdentity }) => {
queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(machineIdentity.organization)); queryClient.invalidateQueries(organizationKeys.getOrgServiceMemberships(machineIdentity.organization));
} }
}); });
}; };
export const useCreateMachineIdentityClientSecret = () => {
const queryClient = useQueryClient();
return useMutation<CreateMachineIdentityClientSecretRes, {}, CreateMachineIdentityClientSecretDTO>({
mutationFn: async ({
machineId,
description,
ttl,
usageLimit
}) => {
const { data } = await apiRequest.post(`/api/v3/machines/${machineId}/client-secrets`, {
machineId,
description,
ttl,
usageLimit
});
return data;
},
onSuccess: (_, { machineId }) => {
queryClient.invalidateQueries(machineIdentityKeys.getMachineIdentityClientSecrets(machineId));
}
});
};
export const useDeleteMachineIdentityClientSecret = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
machineId,
clientSecretId
}: {
machineId:string;
clientSecretId: string;
}) => {
const { data } = await apiRequest.delete(`/api/v3/machines/${machineId}/client-secrets/${clientSecretId}`);
return data;
},
onSuccess: (_, { machineId }) => {
queryClient.invalidateQueries(machineIdentityKeys.getMachineIdentityClientSecrets(machineId));
}
});
};
export const useUpdateMachineIdentity = () => { export const useUpdateMachineIdentity = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<MachineIdentity, {}, UpdateMachineIdentityDTO>({ return useMutation<MachineIdentity, {}, UpdateMachineIdentityDTO>({
@@ -30,21 +78,17 @@ export const useUpdateMachineIdentity = () => {
machineId, machineId,
name, name,
role, role,
isActive, clientSecretTrustedIps,
trustedIps, accessTokenTrustedIps,
expiresIn, accessTokenTTL
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => { }) => {
const { data: { machineIdentity } } = await apiRequest.patch(`/api/v3/machines/${machineId}`, { const { data: { machineIdentity } } = await apiRequest.patch(`/api/v3/machines/${machineId}`, {
name, name,
role, role,
isActive, clientSecretTrustedIps,
trustedIps, accessTokenTrustedIps,
expiresIn,
accessTokenTTL, accessTokenTTL,
isRefreshTokenRotationEnabled
}); });
return machineIdentity; return machineIdentity;
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
MachineIdentityClientSecret
} from "./types";
export const machineIdentityKeys = {
getMachineIdentityClientSecrets: (machineId: string) => [{ machineId }, "machine-identity-client-secrets"] as const
}
export const useGetMachineIdentityClientSecrets = (machineId: string) => {
return useQuery({
queryKey: machineIdentityKeys.getMachineIdentityClientSecrets(machineId),
queryFn: async () => {
const { data: { clientSecretData } } = await apiRequest.get<{ clientSecretData: MachineIdentityClientSecret[] }>(
`/api/v3/machines/${machineId}/client-secrets`
);
return clientSecretData;
}
});
}
@@ -9,21 +9,30 @@ export type MachineTrustedIp = {
export type MachineIdentity = { export type MachineIdentity = {
_id: string; _id: string;
clientId: string;
name: string; name: string;
organization: string; organization: string;
isActive: boolean; isActive: boolean;
refreshTokenLastUsed?: string;
accessTokenLastUsed?: string;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
trustedIps: MachineTrustedIp[];
expiresAt?: string;
accessTokenTTL: number; accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean; accessTokenLastUsed?: string;
accessTokenUsageCount: number;
clientSecretTrustedIps: MachineTrustedIp[];
accessTokenTrustedIps: MachineTrustedIp[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type MachineIdentityClientSecret = {
_id: string;
machineIdentity: string;
isActive: boolean;
description: string;
clientSecretPrefix: string;
clientSecretUsageCount: number;
clientSecretUsageLimit: number;
expiresAt: string;
}
export type MachineMembershipOrg = { export type MachineMembershipOrg = {
_id: string; _id: string;
machineIdentity: MachineIdentity; machineIdentity: MachineIdentity;
@@ -48,30 +57,47 @@ export type CreateMachineIdentityDTO = {
name: string; name: string;
organizationId: string; organizationId: string;
role?: string; role?: string;
trustedIps: { clientSecretTrustedIps: {
ipAddress: string;
}[];
accessTokenTrustedIps: {
ipAddress: string; ipAddress: string;
}[]; }[];
expiresIn?: number;
accessTokenTTL: number; accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean; }
export type CreateMachineIdentityClientSecretDTO = {
machineId: string;
description?: string;
ttl?: number;
usageLimit?: number;
}
export type CreateMachineIdentityClientSecretRes = {
clientSecret: string;
machineIdentity: string;
isActive: boolean;
description: string;
clientSecretUsageCount: number;
clientSecretUsageLimit: number;
expiresAt?: Date;
} }
export type CreateMachineIdentityRes = { export type CreateMachineIdentityRes = {
refreshToken: string;
machineIdentity: MachineIdentity; machineIdentity: MachineIdentity;
} }
export type UpdateMachineIdentityDTO = { export type UpdateMachineIdentityDTO = {
machineId: string; machineId: string;
isActive?: boolean;
name?: string; name?: string;
role?: string; role?: string;
trustedIps?: { clientSecretTrustedIps?: {
ipAddress: string;
}[];
accessTokenTrustedIps?: {
ipAddress: string; ipAddress: string;
}[]; }[];
expiresIn?: number;
accessTokenTTL?: number; accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
} }
export type DeleteMachineIdentityDTO = { export type DeleteMachineIdentityDTO = {
@@ -27,7 +27,7 @@ export const MembersPage = withPermission(
<Tab value={TabSections.Member}>People</Tab> <Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.MachineIdentities}> <Tab value={TabSections.MachineIdentities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>App Clients</p>
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default"> <div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
Beta Beta
</div> </div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@@ -16,7 +16,6 @@ import {
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
Switch,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
@@ -40,18 +39,8 @@ enum TabSections {
Advanced = "advanced" Advanced = "advanced"
} }
const expirations = [
{ label: "Never", value: "" },
{ label: "1 day", value: "86400" },
{ label: "7 days", value: "604800" },
{ label: "1 month", value: "2592000" },
{ label: "6 months", value: "15552000" },
{ label: "12 months", value: "31104000" }
];
const schema = yup.object({ const schema = yup.object({
name: yup.string().required("MI name is required"), name: yup.string().required("MI name is required"),
expiresIn: yup.string(),
accessTokenTTL: yup accessTokenTTL: yup
.string() .string()
.test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => { .test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => {
@@ -64,7 +53,7 @@ const schema = yup.object({
}) })
.required("Access Token TTL is required"), .required("Access Token TTL is required"),
role: yup.string(), role: yup.string(),
trustedIps: yup clientSecretTrustedIps: yup
.array( .array(
yup.object({ yup.object({
ipAddress: yup.string().max(50).required().label("IP Address") ipAddress: yup.string().max(50).required().label("IP Address")
@@ -72,8 +61,16 @@ const schema = yup.object({
) )
.min(1) .min(1)
.required() .required()
.label("Trusted IP"), .label("Client Secret Trusted IP"),
isRefreshTokenRotationEnabled: yup.boolean().default(false) accessTokenTrustedIps: yup
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
})
)
.min(1)
.required()
.label("Access Token Trusted IP")
}).required(); }).required();
export type FormData = yup.InferType<typeof schema>; export type FormData = yup.InferType<typeof schema>;
@@ -90,7 +87,6 @@ export const AddMachineIdentityModal = ({
handlePopUpToggle handlePopUpToggle
}: Props) => { }: Props) => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const [newServiceTokenJSON, setNewServiceTokenJSON] = useState("");
const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false); const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
@@ -115,9 +111,12 @@ export const AddMachineIdentityModal = ({
defaultValues: { defaultValues: {
name: "", name: "",
accessTokenTTL: "7200", accessTokenTTL: "7200",
trustedIps: [{ clientSecretTrustedIps: [{
ipAddress: "0.0.0.0/0" ipAddress: "0.0.0.0/0"
}] }],
accessTokenTrustedIps: [{
ipAddress: "0.0.0.0/0"
}],
} }
}); });
@@ -131,11 +130,6 @@ export const AddMachineIdentityModal = ({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [setIsServiceTokenJSONCopied]); }, [setIsServiceTokenJSONCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newServiceTokenJSON);
setIsServiceTokenJSONCopied.on();
};
useEffect(() => { useEffect(() => {
const machineIdentity = popUp?.machineIdentity?.data as { const machineIdentity = popUp?.machineIdentity?.data as {
@@ -146,9 +140,9 @@ export const AddMachineIdentityModal = ({
name: string; name: string;
slug: string; slug: string;
}; };
trustedIps: MachineTrustedIp[]; clientSecretTrustedIps: MachineTrustedIp[];
accessTokenTrustedIps: MachineTrustedIp[];
accessTokenTTL: number; accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
}; };
if (!roles?.length) return; if (!roles?.length) return;
@@ -156,9 +150,8 @@ export const AddMachineIdentityModal = ({
if (machineIdentity) { if (machineIdentity) {
reset({ reset({
name: machineIdentity.name, name: machineIdentity.name,
expiresIn: "",
role: machineIdentity?.customRole?.slug ?? machineIdentity.role, role: machineIdentity?.customRole?.slug ?? machineIdentity.role,
trustedIps: machineIdentity.trustedIps.map(({ clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps.map(({
ipAddress, ipAddress,
prefix prefix
}: MachineTrustedIp) => { }: MachineTrustedIp) => {
@@ -166,31 +159,48 @@ export const AddMachineIdentityModal = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
}); });
}), }),
accessTokenTTL: String(machineIdentity.accessTokenTTL), accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps.map(({
isRefreshTokenRotationEnabled: machineIdentity.isRefreshTokenRotationEnabled ipAddress,
prefix
}: MachineTrustedIp) => {
return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
}),
accessTokenTTL: String(machineIdentity.accessTokenTTL)
}); });
} else { } else {
reset({ reset({
name: "", name: "",
expiresIn: "",
accessTokenTTL: "7200", accessTokenTTL: "7200",
role: roles[0].slug, role: roles[0].slug,
trustedIps: [{ clientSecretTrustedIps: [{
ipAddress: "0.0.0.0/0"
}],
accessTokenTrustedIps: [{
ipAddress: "0.0.0.0/0" ipAddress: "0.0.0.0/0"
}] }]
}); });
} }
}, [popUp?.machineIdentity?.data, roles]); }, [popUp?.machineIdentity?.data, roles]);
const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" }); const {
fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp,
remove: removeClientSecretTrustedIp
} = useFieldArray({ control, name: "clientSecretTrustedIps" });
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
remove: removeAccessTokenTrustedIp
} = useFieldArray({ control, name: "accessTokenTrustedIps" });
const onFormSubmit = async ({ const onFormSubmit = async ({
name, name,
expiresIn,
accessTokenTTL, accessTokenTTL,
role, role,
trustedIps, clientSecretTrustedIps,
isRefreshTokenRotationEnabled accessTokenTrustedIps
}: FormData) => { }: FormData) => {
try { try {
@@ -207,26 +217,24 @@ export const AddMachineIdentityModal = ({
machineId: machineIdentity.machineId, machineId: machineIdentity.machineId,
name, name,
role: role || undefined, role: role || undefined,
trustedIps, clientSecretTrustedIps,
expiresIn: (!expiresIn) ? undefined : Number(expiresIn), accessTokenTrustedIps,
accessTokenTTL: Number(accessTokenTTL), accessTokenTTL: Number(accessTokenTTL)
isRefreshTokenRotationEnabled
}); });
handlePopUpToggle("machineIdentity", false); handlePopUpToggle("machineIdentity", false);
} else { } else {
const { refreshToken } = await createMutateAsync({ await createMutateAsync({
name, name,
role: role || undefined, role: role || undefined,
organizationId: orgId, organizationId: orgId,
trustedIps, clientSecretTrustedIps,
expiresIn: (!expiresIn) ? undefined : Number(expiresIn), accessTokenTrustedIps,
accessTokenTTL: Number(accessTokenTTL), accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
}); });
setNewServiceTokenJSON(refreshToken); handlePopUpToggle("machineIdentity", false);
} }
createNotification({ createNotification({
@@ -247,248 +255,280 @@ export const AddMachineIdentityModal = ({
} }
} }
const hasServiceTokenJSON = Boolean(newServiceTokenJSON);
return ( return (
<Modal <Modal
isOpen={popUp?.machineIdentity?.isOpen} isOpen={popUp?.machineIdentity?.isOpen}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("machineIdentity", isOpen); handlePopUpToggle("machineIdentity", isOpen);
reset(); reset();
setNewServiceTokenJSON("");
}} }}
> >
<ModalContent title={`${popUp?.machineIdentity?.data ? "Update" : "Create"} Machine Identity`}> <ModalContent title={`${popUp?.machineIdentity?.data ? "Update" : "Create"} App Client`}>
{!hasServiceTokenJSON ? ( <form onSubmit={handleSubmit(onFormSubmit)}>
<form onSubmit={handleSubmit(onFormSubmit)}> <Tabs defaultValue={TabSections.General}>
<Tabs defaultValue={TabSections.General}> <TabList>
<TabList> <div className="flex flex-row border-b border-mineshaft-600 w-full">
<div className="flex flex-row border-b border-mineshaft-600 w-full"> <Tab value={TabSections.General}>General</Tab>
<Tab value={TabSections.General}>General</Tab> <Tab value={TabSections.Advanced}>Advanced</Tab>
<Tab value={TabSections.Advanced}>Advanced</Tab> </div>
</div> </TabList>
</TabList> <TabPanel value={TabSections.General}>
<TabPanel value={TabSections.General}> <motion.div
<motion.div key="panel-1"
key="panel-1" transition={{ duration: 0.15 }}
transition={{ duration: 0.15 }} initial={{ opacity: 0, translateX: 30 }}
initial={{ opacity: 0, translateX: 30 }} animate={{ opacity: 1, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }}
exit={{ opacity: 0, translateX: 30 }} >
>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Machine 1"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.machineIdentity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Advanced}>
<div>
<Controller <Controller
control={control} control={control}
name="expiresIn" defaultValue=""
defaultValue="" name="name"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label={`${popUp?.machineIdentity?.data ? "Update" : ""} Refresh Token Expires In`} label="Name"
errorText={error?.message} isError={Boolean(error)}
isError={Boolean(error)} errorText={error?.message}
className="mt-4" >
<Input
{...field}
placeholder="Machine 1"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.machineIdentity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
> >
<Select {(roles || []).map(({ name, slug }) => (
defaultValue={field.value} <SelectItem value={slug} key={`st-role-${slug}`}>
{...field} {name}
onValueChange={(e) => onChange(e)} </SelectItem>
className="w-full" ))}
> </Select>
{expirations.map(({ label, value }) => ( </FormControl>
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}> )}
{label} />
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{tokenTrustedIps.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`trustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Trusted IP" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan"); </motion.div>
}} </TabPanel>
placeholder="123.456.789.0" <TabPanel value={TabSections.Advanced}>
/> <div>
</FormControl> {/* <Controller
); control={control}
}} name="expiresIn"
/> defaultValue=""
<IconButton render={({ field: { onChange, ...field }, fieldState: { error } }) => (
onClick={() => { <FormControl
if (subscription?.ipAllowlisting) { label={`${popUp?.machineIdentity?.data ? "Update" : ""} Refresh Token Expires In`}
removeTrustedIp(index); errorText={error?.message}
return; isError={Boolean(error)}
} className="mt-4"
>
handlePopUpOpen("upgradePlan"); <Select
}} defaultValue={field.value}
size="lg" {...field}
colorSchema="danger" onValueChange={(e) => onChange(e)}
variant="plain" className="w-full"
ariaLabel="update"
className="p-3"
> >
<FontAwesomeIcon icon={faXmark} /> {expirations.map(({ label, value }) => (
</IconButton> <SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
</div> {label}
))} </SelectItem>
<div className="my-4 ml-1"> ))}
<Button </Select>
variant="outline_bg" </FormControl>
)}
/> */}
<Controller
control={control}
defaultValue="7200"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="7200"
/>
</FormControl>
)}
/>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`clientSecretTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Client Secret Trusted IPs" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => { onClick={() => {
if (subscription?.ipAllowlisting) { if (subscription?.ipAllowlisting) {
appendTrustedIp({ removeClientSecretTrustedIp(index);
ipAddress: "0.0.0.0/0"
})
return; return;
} }
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
}} }}
leftIcon={<FontAwesomeIcon icon={faPlus} />} size="lg"
size="xs" colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
> >
Add IP Address <FontAwesomeIcon icon={faXmark} />
</Button> </IconButton>
</div> </div>
<Controller ))}
control={control} <div className="my-4 ml-1">
defaultValue="7200" <Button
name="accessTokenTTL" variant="outline_bg"
render={({ field, fieldState: { error } }) => ( onClick={() => {
<FormControl if (subscription?.ipAllowlisting) {
label="Access Token TTL (seconds)" appendClientSecretTrustedIp({
isError={Boolean(error)} ipAddress: "0.0.0.0/0"
errorText={error?.message} })
> return;
<Input }
{...field}
placeholder="7200" handlePopUpOpen("upgradePlan");
/> }}
</FormControl> leftIcon={<FontAwesomeIcon icon={faPlus} />}
)} size="xs"
/> >
<div className="mt-8"> Add IP Address
</Button>
</div>
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller <Controller
control={control} control={control}
name="isRefreshTokenRotationEnabled" name={`accessTokenTrustedIps.${index}.ipAddress`}
render={({ field: { onChange, value } }) => ( defaultValue="0.0.0.0/0"
<Switch render={({ field, fieldState: { error } }) => {
id="label-refresh-token-rotation" return (
onCheckedChange={(isChecked) => onChange(isChecked)} <FormControl
isChecked={value} className="mb-0 flex-grow"
> label={index === 0 ? "Access Token Trusted IPs" : undefined}
Refresh Token Rotation isError={Boolean(error)}
</Switch> errorText={error?.message}
)} >
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/> />
<p className="mt-4 text-sm font-normal text-mineshaft-400">When enabled, as a result of exchanging a refresh token, a new refresh token will be issued and the existing token will be invalidated.</p> <IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeAccessTokenTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div> </div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendAccessTokenTrustedIp({
ipAddress: "0.0.0.0/0"
})
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div> </div>
</TabPanel> </div>
</Tabs> </TabPanel>
<div className="flex items-center"> </Tabs>
<Button <div className="flex items-center">
className="mr-4" <Button
size="sm" className="mr-4"
type="submit" size="sm"
isLoading={isSubmitting} type="submit"
isDisabled={isSubmitting} isLoading={isSubmitting}
> isDisabled={isSubmitting}
{popUp?.machineIdentity?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("machineIdentity", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newServiceTokenJSON}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
> >
<FontAwesomeIcon icon={isServiceTokenJSONCopied ? faCheck : faCopy} /> {popUp?.machineIdentity?.data ? "Update" : "Create"}
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn"> </Button>
Click to copy <Button
</span> colorSchema="secondary"
</IconButton> variant="plain"
onClick={() => handlePopUpToggle("machineIdentity", false)}
>
Cancel
</Button>
</div> </div>
)} </form>
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen} isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -0,0 +1,284 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey,faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { format } from "date-fns";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent
,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import {
useCreateMachineIdentityClientSecret,
useDeleteMachineIdentityClientSecret,
useGetMachineIdentityClientSecrets} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup.object({
description: yup.string(),
ttl: yup.string() // TODO: optional
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["clientSecret"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["clientSecret"]>, state?: boolean) => void;
};
export const CreateClientSecretModal = ({
popUp,
handlePopUpToggle
}: Props) => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const [token, setToken] = useState("");
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
const popUpData = (popUp?.clientSecret?.data as {
machineId?: string;
name?: string;
});
const { data, isLoading } = useGetMachineIdentityClientSecrets(popUpData?.machineId ?? "");
const { mutateAsync: createClientSecretMutateAsync } = useCreateMachineIdentityClientSecret();
const { mutateAsync: deleteClientSecretMutateAsync } = useDeleteMachineIdentityClientSecret();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
description: "",
ttl: ""
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(token);
setIsTokenCopied.on();
};
const onFormSubmit = async ({
description,
ttl
}: FormData) => {
try {
if (popUpData) {
const { clientSecret } = await createClientSecretMutateAsync({
machineId: popUpData.machineId,
description,
ttl: Number(ttl)
});
setToken(clientSecret);
}
createNotification({
text: "Successfully created client secret",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create client secret",
type: "error"
});
}
}
const hasToken = Boolean(token);
return (
<Modal
isOpen={popUp?.clientSecret?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("clientSecret", isOpen);
reset();
setToken("");
}}
>
<ModalContent title={`Manage Client Secrets for ${popUpData?.name ?? ""}`}>
<h2 className="mb-4">New Client Secret</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this secret once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="flex mb-8"
>
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Description"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (optional)"
isError={Boolean(error)}
errorText={error?.message}
className="ml-4"
>
<div className="flex">
<Input
{...field}
placeholder="7200"
type="number"
min="0"
step="1"
/>
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">Client Secrets</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Expires At</Th>
<Th>Client Secret</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-machine-identities-client-secrets" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(({
_id,
description,
machineIdentity,
expiresAt,
clientSecretPrefix
}) => {
return (
<Tr className="h-10" key={`mi-client-secret-${_id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td>
<Td>{`${clientSecretPrefix}************`}</Td>
<Td className="flex">
<IconButton
onClick={async () => {
await deleteClientSecretMutateAsync({
machineId: machineIdentity,
clientSecretId: _id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
className="ml-4"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No client secrets have been created for this app client yet" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
);
}
@@ -13,6 +13,7 @@ import { useDeleteMachineIdentity } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { AddMachineIdentityModal } from "./AddMachineIdentityModal"; import { AddMachineIdentityModal } from "./AddMachineIdentityModal";
import { CreateClientSecretModal } from "./CreateClientSecretModal";
import { MachineIdentityTable } from "./MachineIdentityTable"; import { MachineIdentityTable } from "./MachineIdentityTable";
export const MachineIdentitySection = withPermission( export const MachineIdentitySection = withPermission(
@@ -22,6 +23,7 @@ export const MachineIdentitySection = withPermission(
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"machineIdentity", "machineIdentity",
"deleteMachineIdentity", "deleteMachineIdentity",
"clientSecret",
"upgradePlan" "upgradePlan"
] as const); ] as const);
@@ -49,7 +51,7 @@ export const MachineIdentitySection = withPermission(
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100"> <p className="text-xl font-semibold text-mineshaft-100">
Machine Identities (MIs) App Clients
</p> </p>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Create} I={OrgPermissionActions.Create}
@@ -63,7 +65,7 @@ export const MachineIdentitySection = withPermission(
onClick={() => handlePopUpOpen("machineIdentity")} onClick={() => handlePopUpOpen("machineIdentity")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create MI Create client
</Button> </Button>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
@@ -76,6 +78,10 @@ export const MachineIdentitySection = withPermission(
handlePopUpOpen={handlePopUpOpen} handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle} handlePopUpToggle={handlePopUpToggle}
/> />
<CreateClientSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteMachineIdentity.isOpen} isOpen={popUp.deleteMachineIdentity.isOpen}
title={`Are you sure want to delete ${ title={`Are you sure want to delete ${
@@ -1,6 +1,5 @@
import { faPencil,faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faKey,faPencil,faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
@@ -16,8 +15,8 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr Tooltip,
} from "@app/components/v2"; Tr} from "@app/components/v2";
import { import {
OrgPermissionActions, OrgPermissionActions,
OrgPermissionSubjects, OrgPermissionSubjects,
@@ -25,14 +24,14 @@ import {
import { import {
useGetMachineMembershipOrgs, useGetMachineMembershipOrgs,
useGetRoles, useGetRoles,
useUpdateMachineIdentity useUpdateMachineIdentity,
} from "@app/hooks/api"; } from "@app/hooks/api";
import { MachineTrustedIp } from "@app/hooks/api/machineIdentities/types"; import { MachineTrustedIp } from "@app/hooks/api/machineIdentities/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteMachineIdentity", "machineIdentity"]>, popUpName: keyof UsePopUpState<["deleteMachineIdentity", "machineIdentity", "clientSecret"]>,
data?: { data?: {
machineId?: string; machineId?: string;
name?: string; name?: string;
@@ -41,9 +40,9 @@ type Props = {
name: string; name: string;
slug: string; slug: string;
}; };
trustedIps?: MachineTrustedIp[]; clientSecretTrustedIps?: MachineTrustedIp[];
accessTokenTrustedIps?: MachineTrustedIp[];
accessTokenTTL?: number; accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
} }
) => void; ) => void;
}; };
@@ -121,12 +120,13 @@ export const MachineIdentityTable = ({
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Client ID</Th>
{/* <Th>Status</Th> */} {/* <Th>Status</Th> */}
<Th>Role</Th> <Th>Role</Th>
{/* <Th>Trusted IPs</Th> */} {/* <Th>Trusted IPs</Th> */}
{/* <Th>Access Token TTL</Th> */} {/* <Th>Access Token TTL</Th> */}
{/* <Th>Created At</Th> */} {/* <Th>Created At</Th> */}
<Th>Valid Until</Th> {/* <Th>Valid Until</Th> */}
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -139,12 +139,13 @@ export const MachineIdentityTable = ({
machineIdentity: { machineIdentity: {
_id, _id,
name, name,
clientId,
// isActive, // isActive,
trustedIps, clientSecretTrustedIps,
accessTokenTrustedIps,
// createdAt, // createdAt,
expiresAt, // expiresAt,
accessTokenTTL, accessTokenTTL,
isRefreshTokenRotationEnabled
}, },
role, role,
customRole customRole
@@ -152,6 +153,7 @@ export const MachineIdentityTable = ({
return ( return (
<Tr className="h-10" key={`st-v3-${_id}`}> <Tr className="h-10" key={`st-v3-${_id}`}>
<Td>{name}</Td> <Td>{name}</Td>
<Td>{clientId}</Td>
{/* <Td> {/* <Td>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Edit} I={OrgPermissionActions.Edit}
@@ -219,8 +221,26 @@ export const MachineIdentityTable = ({
</Td> */} </Td> */}
{/* <Td>{accessTokenTTL}</Td> */} {/* <Td>{accessTokenTTL}</Td> */}
{/* <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> */} {/* <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> */}
<Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td> {/* <Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td> */}
<Td className="flex justify-end"> <Td className="flex justify-end">
<Tooltip content="Manage client secrets">
<IconButton
onClick={async () => {
handlePopUpOpen("clientSecret", {
machineId: _id,
name
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
// isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faKey} />
</IconButton>
</Tooltip>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Edit} I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.MachineIdentity} a={OrgPermissionSubjects.MachineIdentity}
@@ -233,15 +253,16 @@ export const MachineIdentityTable = ({
name, name,
role, role,
customRole, customRole,
trustedIps, clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL, accessTokenTTL,
isRefreshTokenRotationEnabled
}); });
}} }}
size="lg" size="lg"
colorSchema="primary" colorSchema="primary"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
<FontAwesomeIcon icon={faPencil} /> <FontAwesomeIcon icon={faPencil} />
@@ -278,7 +299,7 @@ export const MachineIdentityTable = ({
{!isLoading && data && data?.length === 0 && ( {!isLoading && data && data?.length === 0 && (
<Tr> <Tr>
<Td colSpan={7}> <Td colSpan={7}>
<EmptyState title="No MIs have been created in this organization" icon={faServer} /> <EmptyState title="No app clients have been created in this organization" icon={faServer} />
</Td> </Td>
</Tr> </Tr>
)} )}
@@ -32,7 +32,7 @@ export const MembersPage = withProjectPermission(
<Tab value={TabSections.Member}>People</Tab> <Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.MachineIdentities}> <Tab value={TabSections.MachineIdentities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>App Clients</p>
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default"> <div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
Beta Beta
</div> </div>
@@ -120,7 +120,7 @@ export const AddMachineIdentityModal = ({
reset(); reset();
}} }}
> >
<ModalContent title="Add Machine Identity to Project"> <ModalContent title="Add App Client to Project">
{filteredMachineMembershipOrgs.length ? ( {filteredMachineMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<Controller <Controller
@@ -129,7 +129,7 @@ export const AddMachineIdentityModal = ({
defaultValue={filteredMachineMembershipOrgs?.[0]?._id} defaultValue={filteredMachineMembershipOrgs?.[0]?._id}
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Machine Identity" label="App Client"
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
> >
@@ -62,7 +62,7 @@ export const MachineIdentitySection = withProjectPermission(
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100"> <p className="text-xl font-semibold text-mineshaft-100">
Machine Identities (MIs) App Clients
</p> </p>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
@@ -76,7 +76,7 @@ export const MachineIdentitySection = withProjectPermission(
onClick={() => handlePopUpOpen("machineIdentity")} onClick={() => handlePopUpOpen("machineIdentity")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Add MI Add client
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
@@ -188,7 +188,7 @@ export const MachineIdentityTable = ({
{!isLoading && data && data?.length === 0 && ( {!isLoading && data && data?.length === 0 && (
<Tr> <Tr>
<Td colSpan={7}> <Td colSpan={7}>
<EmptyState title="No MIs have been added to this project" icon={faServer} /> <EmptyState title="No app clients have been added to this project" icon={faServer} />
</Td> </Td>
</Tr> </Tr>
)} )}