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

View File

@@ -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;

View File

@@ -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>,
}
},
{

View File

@@ -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>;
};
}

View File

@@ -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(

View File

@@ -69,7 +69,7 @@ class EELicenseService {
rbac: false,
customRateLimits: false,
customAlerts: false,
auditLogs: false,
auditLogs: true,
auditLogsRetentionDays: 0,
samlSSO: false,
status: null,

View File

@@ -327,7 +327,7 @@ export const getAuthDataProjectPermissions = async ({
checkIPAgainstBlocklist({
ipAddress: authData.ipAddress,
trustedIps: machineMembership.machineIdentity.trustedIps
trustedIps: machineMembership.machineIdentity.accessTokenTrustedIps
});
role = machineMembership.role;

View File

@@ -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

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -39,6 +39,6 @@ export const getAuthDataPayloadUserObj = (authData: AuthData) => {
}
if (authData.authPayload instanceof MachineIdentity) {
return { user: authData.authPayload.user };
return {};
}
}

View File

@@ -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()
}),
});

View File

@@ -1,4 +1,9 @@
export {
useCreateMachineIdentity,
useCreateMachineIdentityClientSecret,
useDeleteMachineIdentity,
useUpdateMachineIdentity} from "./mutations";
useDeleteMachineIdentityClientSecret,
useUpdateMachineIdentity} from "./mutations";
export {
useGetMachineIdentityClientSecrets
} from "./queries";

View File

@@ -3,12 +3,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries";
import { machineIdentityKeys } from "./queries";
import {
CreateMachineIdentityClientSecretDTO,
CreateMachineIdentityClientSecretRes,
CreateMachineIdentityDTO,
CreateMachineIdentityRes,
DeleteMachineIdentityDTO,
MachineIdentity,
UpdateMachineIdentityDTO} from "./types";
UpdateMachineIdentityDTO,
} from "./types";
export const useCreateMachineIdentity = () => {
const queryClient = useQueryClient();
@@ -17,12 +21,56 @@ export const useCreateMachineIdentity = () => {
const { data } = await apiRequest.post("/api/v3/machines/", body);
return data;
},
onSuccess: ({ machineIdentity }) => {
onSuccess: ({ machineIdentity }) => {
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 = () => {
const queryClient = useQueryClient();
return useMutation<MachineIdentity, {}, UpdateMachineIdentityDTO>({
@@ -30,21 +78,17 @@ export const useUpdateMachineIdentity = () => {
machineId,
name,
role,
isActive,
trustedIps,
expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL
}) => {
const { data: { machineIdentity } } = await apiRequest.patch(`/api/v3/machines/${machineId}`, {
name,
role,
isActive,
trustedIps,
expiresIn,
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL,
isRefreshTokenRotationEnabled
});
return machineIdentity;

View File

@@ -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;
}
});
}

View File

@@ -9,21 +9,30 @@ export type MachineTrustedIp = {
export type MachineIdentity = {
_id: string;
clientId: string;
name: string;
organization: string;
isActive: boolean;
refreshTokenLastUsed?: string;
accessTokenLastUsed?: string;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
trustedIps: MachineTrustedIp[];
expiresAt?: string;
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
accessTokenLastUsed?: string;
accessTokenUsageCount: number;
clientSecretTrustedIps: MachineTrustedIp[];
accessTokenTrustedIps: MachineTrustedIp[];
createdAt: 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 = {
_id: string;
machineIdentity: MachineIdentity;
@@ -48,30 +57,47 @@ export type CreateMachineIdentityDTO = {
name: string;
organizationId: string;
role?: string;
trustedIps: {
clientSecretTrustedIps: {
ipAddress: string;
}[];
accessTokenTrustedIps: {
ipAddress: string;
}[];
expiresIn?: 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 = {
refreshToken: string;
machineIdentity: MachineIdentity;
}
export type UpdateMachineIdentityDTO = {
machineId: string;
isActive?: boolean;
name?: string;
role?: string;
trustedIps?: {
clientSecretTrustedIps?: {
ipAddress: string;
}[];
accessTokenTrustedIps?: {
ipAddress: string;
}[];
expiresIn?: number;
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
}
export type DeleteMachineIdentityDTO = {

View File

@@ -27,7 +27,7 @@ export const MembersPage = withPermission(
<Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.MachineIdentities}>
<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">
Beta
</div>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
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 { yupResolver } from "@hookform/resolvers/yup";
import { motion } from "framer-motion";
@@ -16,7 +16,6 @@ import {
ModalContent,
Select,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
@@ -40,18 +39,8 @@ enum TabSections {
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({
name: yup.string().required("MI name is required"),
expiresIn: yup.string(),
accessTokenTTL: yup
.string()
.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"),
role: yup.string(),
trustedIps: yup
clientSecretTrustedIps: yup
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
@@ -72,8 +61,16 @@ const schema = yup.object({
)
.min(1)
.required()
.label("Trusted IP"),
isRefreshTokenRotationEnabled: yup.boolean().default(false)
.label("Client Secret Trusted IP"),
accessTokenTrustedIps: yup
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
})
)
.min(1)
.required()
.label("Access Token Trusted IP")
}).required();
export type FormData = yup.InferType<typeof schema>;
@@ -90,7 +87,6 @@ export const AddMachineIdentityModal = ({
handlePopUpToggle
}: Props) => {
const { createNotification } = useNotificationContext();
const [newServiceTokenJSON, setNewServiceTokenJSON] = useState("");
const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false);
const { subscription } = useSubscription();
@@ -115,9 +111,12 @@ export const AddMachineIdentityModal = ({
defaultValues: {
name: "",
accessTokenTTL: "7200",
trustedIps: [{
clientSecretTrustedIps: [{
ipAddress: "0.0.0.0/0"
}]
}],
accessTokenTrustedIps: [{
ipAddress: "0.0.0.0/0"
}],
}
});
@@ -130,11 +129,6 @@ export const AddMachineIdentityModal = ({
return () => clearTimeout(timer);
}, [setIsServiceTokenJSONCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newServiceTokenJSON);
setIsServiceTokenJSONCopied.on();
};
useEffect(() => {
@@ -146,9 +140,9 @@ export const AddMachineIdentityModal = ({
name: string;
slug: string;
};
trustedIps: MachineTrustedIp[];
clientSecretTrustedIps: MachineTrustedIp[];
accessTokenTrustedIps: MachineTrustedIp[];
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
};
if (!roles?.length) return;
@@ -156,9 +150,8 @@ export const AddMachineIdentityModal = ({
if (machineIdentity) {
reset({
name: machineIdentity.name,
expiresIn: "",
role: machineIdentity?.customRole?.slug ?? machineIdentity.role,
trustedIps: machineIdentity.trustedIps.map(({
clientSecretTrustedIps: machineIdentity.clientSecretTrustedIps.map(({
ipAddress,
prefix
}: MachineTrustedIp) => {
@@ -166,31 +159,48 @@ export const AddMachineIdentityModal = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
}),
accessTokenTTL: String(machineIdentity.accessTokenTTL),
isRefreshTokenRotationEnabled: machineIdentity.isRefreshTokenRotationEnabled
accessTokenTrustedIps: machineIdentity.accessTokenTrustedIps.map(({
ipAddress,
prefix
}: MachineTrustedIp) => {
return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
}),
accessTokenTTL: String(machineIdentity.accessTokenTTL)
});
} else {
reset({
name: "",
expiresIn: "",
accessTokenTTL: "7200",
role: roles[0].slug,
trustedIps: [{
clientSecretTrustedIps: [{
ipAddress: "0.0.0.0/0"
}],
accessTokenTrustedIps: [{
ipAddress: "0.0.0.0/0"
}]
});
}
}, [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 ({
name,
expiresIn,
accessTokenTTL,
role,
trustedIps,
isRefreshTokenRotationEnabled
clientSecretTrustedIps,
accessTokenTrustedIps
}: FormData) => {
try {
@@ -207,26 +217,24 @@ export const AddMachineIdentityModal = ({
machineId: machineIdentity.machineId,
name,
role: role || undefined,
trustedIps,
expiresIn: (!expiresIn) ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL: Number(accessTokenTTL)
});
handlePopUpToggle("machineIdentity", false);
} else {
const { refreshToken } = await createMutateAsync({
await createMutateAsync({
name,
role: role || undefined,
organizationId: orgId,
trustedIps,
expiresIn: (!expiresIn) ? undefined : Number(expiresIn),
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
});
setNewServiceTokenJSON(refreshToken);
handlePopUpToggle("machineIdentity", false);
}
createNotification({
@@ -247,248 +255,280 @@ export const AddMachineIdentityModal = ({
}
}
const hasServiceTokenJSON = Boolean(newServiceTokenJSON);
return (
<Modal
isOpen={popUp?.machineIdentity?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("machineIdentity", isOpen);
reset();
setNewServiceTokenJSON("");
}}
>
<ModalContent title={`${popUp?.machineIdentity?.data ? "Update" : "Create"} Machine Identity`}>
{!hasServiceTokenJSON ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Tabs defaultValue={TabSections.General}>
<TabList>
<div className="flex flex-row border-b border-mineshaft-600 w-full">
<Tab value={TabSections.General}>General</Tab>
<Tab value={TabSections.Advanced}>Advanced</Tab>
</div>
</TabList>
<TabPanel value={TabSections.General}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
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>
<ModalContent title={`${popUp?.machineIdentity?.data ? "Update" : "Create"} App Client`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Tabs defaultValue={TabSections.General}>
<TabList>
<div className="flex flex-row border-b border-mineshaft-600 w-full">
<Tab value={TabSections.General}>General</Tab>
<Tab value={TabSections.Advanced}>Advanced</Tab>
</div>
</TabList>
<TabPanel value={TabSections.General}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<Controller
control={control}
name="expiresIn"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.machineIdentity?.data ? "Update" : ""} Refresh Token Expires In`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<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");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendTrustedIp({
ipAddress: "0.0.0.0/0"
})
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
Add IP Address
</Button>
</div>
<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="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"
>
<Input
{...field}
placeholder="7200"
/>
</FormControl>
)}
/>
<div className="mt-8">
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Advanced}>
<div>
{/* <Controller
control={control}
name="expiresIn"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.machineIdentity?.data ? "Update" : ""} Refresh Token Expires In`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{label}
</SelectItem>
))}
</Select>
</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="isRefreshTokenRotationEnabled"
render={({ field: { onChange, value } }) => (
<Switch
id="label-refresh-token-rotation"
onCheckedChange={(isChecked) => onChange(isChecked)}
isChecked={value}
>
Refresh Token Rotation
</Switch>
)}
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>
);
}}
/>
<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) {
removeClientSecretTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendClientSecretTrustedIp({
ipAddress: "0.0.0.0/0"
})
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
</TabPanel>
</Tabs>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
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}
{accessTokenTrustedIpsFields.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`accessTokenTrustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Access Token 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={() => {
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 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>
</Tabs>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={isServiceTokenJSONCopied ? 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">
Click to copy
</span>
</IconButton>
{popUp?.machineIdentity?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("machineIdentity", false)}
>
Cancel
</Button>
</div>
)}
</form>
<UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}

View File

@@ -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>
);
}

View File

@@ -13,6 +13,7 @@ import { useDeleteMachineIdentity } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddMachineIdentityModal } from "./AddMachineIdentityModal";
import { CreateClientSecretModal } from "./CreateClientSecretModal";
import { MachineIdentityTable } from "./MachineIdentityTable";
export const MachineIdentitySection = withPermission(
@@ -22,6 +23,7 @@ export const MachineIdentitySection = withPermission(
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"machineIdentity",
"deleteMachineIdentity",
"clientSecret",
"upgradePlan"
] 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="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
Machine Identities (MIs)
App Clients
</p>
<OrgPermissionCan
I={OrgPermissionActions.Create}
@@ -63,7 +65,7 @@ export const MachineIdentitySection = withPermission(
onClick={() => handlePopUpOpen("machineIdentity")}
isDisabled={!isAllowed}
>
Create MI
Create client
</Button>
)}
</OrgPermissionCan>
@@ -76,6 +78,10 @@ export const MachineIdentitySection = withPermission(
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<CreateClientSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteMachineIdentity.isOpen}
title={`Are you sure want to delete ${

View File

@@ -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 { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
@@ -16,8 +15,8 @@ import {
Td,
Th,
THead,
Tr
} from "@app/components/v2";
Tooltip,
Tr} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -25,14 +24,14 @@ import {
import {
useGetMachineMembershipOrgs,
useGetRoles,
useUpdateMachineIdentity
useUpdateMachineIdentity,
} from "@app/hooks/api";
import { MachineTrustedIp } from "@app/hooks/api/machineIdentities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteMachineIdentity", "machineIdentity"]>,
popUpName: keyof UsePopUpState<["deleteMachineIdentity", "machineIdentity", "clientSecret"]>,
data?: {
machineId?: string;
name?: string;
@@ -41,9 +40,9 @@ type Props = {
name: string;
slug: string;
};
trustedIps?: MachineTrustedIp[];
clientSecretTrustedIps?: MachineTrustedIp[];
accessTokenTrustedIps?: MachineTrustedIp[];
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
}
) => void;
};
@@ -121,12 +120,13 @@ export const MachineIdentityTable = ({
<THead>
<Tr>
<Th>Name</Th>
<Th>Client ID</Th>
{/* <Th>Status</Th> */}
<Th>Role</Th>
{/* <Th>Trusted IPs</Th> */}
{/* <Th>Access Token TTL</Th> */}
{/* <Th>Created At</Th> */}
<Th>Valid Until</Th>
{/* <Th>Valid Until</Th> */}
<Th className="w-5" />
</Tr>
</THead>
@@ -139,12 +139,13 @@ export const MachineIdentityTable = ({
machineIdentity: {
_id,
name,
clientId,
// isActive,
trustedIps,
clientSecretTrustedIps,
accessTokenTrustedIps,
// createdAt,
expiresAt,
// expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
},
role,
customRole
@@ -152,6 +153,7 @@ export const MachineIdentityTable = ({
return (
<Tr className="h-10" key={`st-v3-${_id}`}>
<Td>{name}</Td>
<Td>{clientId}</Td>
{/* <Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
@@ -219,8 +221,26 @@ export const MachineIdentityTable = ({
</Td> */}
{/* <Td>{accessTokenTTL}</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">
<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
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.MachineIdentity}
@@ -233,15 +253,16 @@ export const MachineIdentityTable = ({
name,
role,
customRole,
trustedIps,
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenTTL,
isRefreshTokenRotationEnabled
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
@@ -278,7 +299,7 @@ export const MachineIdentityTable = ({
{!isLoading && data && data?.length === 0 && (
<Tr>
<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>
</Tr>
)}

View File

@@ -32,7 +32,7 @@ export const MembersPage = withProjectPermission(
<Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.MachineIdentities}>
<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">
Beta
</div>

View File

@@ -120,7 +120,7 @@ export const AddMachineIdentityModal = ({
reset();
}}
>
<ModalContent title="Add Machine Identity to Project">
<ModalContent title="Add App Client to Project">
{filteredMachineMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
@@ -129,7 +129,7 @@ export const AddMachineIdentityModal = ({
defaultValue={filteredMachineMembershipOrgs?.[0]?._id}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Machine Identity"
label="App Client"
errorText={error?.message}
isError={Boolean(error)}
>

View File

@@ -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="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
Machine Identities (MIs)
App Clients
</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
@@ -76,7 +76,7 @@ export const MachineIdentitySection = withProjectPermission(
onClick={() => handlePopUpOpen("machineIdentity")}
isDisabled={!isAllowed}
>
Add MI
Add client
</Button>
)}
</ProjectPermissionCan>

View File

@@ -188,7 +188,7 @@ export const MachineIdentityTable = ({
{!isLoading && data && data?.length === 0 && (
<Tr>
<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>
</Tr>
)}