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