Merge pull request #1126 from Infisical/stv3-update

Multipart Update to Authentication (ST V3, Modularization of Auth Validation Methods, SSO logic)
pull/1157/head
BlackMagiq 7 months ago committed by GitHub
commit 32882848ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -24,7 +24,6 @@ export const getJwtRefreshLifetime = async () => (await client.getSecret("JWT_RE
export const getJwtServiceSecret = async () => (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; // TODO: deprecate (related to ST V1)
export const getJwtSignupLifetime = async () => (await client.getSecret("JWT_SIGNUP_LIFETIME")).secretValue || "15m";
export const getJwtProviderAuthLifetime = async () => (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m";
export const getJwtServiceTokenSecret = async () => (await client.getSecret("JWT_SERVICE_TOKEN_SECRET")).secretValue;
export const getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue;
export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production";
export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true;

@ -24,10 +24,18 @@ import { validateRequest } from "../../helpers/validation";
import * as reqValidator from "../../validation/auth";
declare module "jsonwebtoken" {
export interface AuthnJwtPayload extends jwt.JwtPayload {
authTokenType: AuthTokenType;
}
export interface UserIDJwtPayload extends jwt.JwtPayload {
userId: string;
refreshVersion?: number;
}
export interface ServiceRefreshTokenJwtPayload extends jwt.JwtPayload {
serviceTokenDataId: string;
authTokenType: string;
tokenVersion: number;
}
}
/**

@ -1,3 +1,4 @@
import jwt from "jsonwebtoken";
import { Request, Response } from "express";
import { Types } from "mongoose";
import {
@ -24,10 +25,11 @@ import {
getUserProjectPermissions
} from "../../services/ProjectRoleService";
import { ForbiddenError } from "@casl/ability";
import { BadRequestError, ResourceNotFoundError } from "../../../utils/errors";
import { BadRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors";
import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip";
import { EEAuditLogService, EELicenseService } from "../../services";
import { getJwtServiceTokenSecret } from "../../../config";
import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables";
/**
* Return project key for service token V3
@ -56,6 +58,99 @@ export const getServiceTokenDataKey = async (req: Request, res: Response) => {
});
}
/**
* Return access and refresh token as per refresh operation
* @param req
* @param res
*/
export const refreshToken = async (req: Request, res: Response) => {
const {
body: {
refresh_token
}
} = await validateRequest(reqValidator.RefreshTokenV3, req);
const decodedToken = <jwt.ServiceRefreshTokenJwtPayload>(
jwt.verify(refresh_token, await getAuthSecret())
);
if (decodedToken.authTokenType !== AuthTokenType.SERVICE_REFRESH_TOKEN) throw UnauthorizedRequestError();
let serviceTokenData = await ServiceTokenDataV3.findOne({
_id: new Types.ObjectId(decodedToken.serviceTokenDataId),
isActive: true
});
if (!serviceTokenData) throw UnauthorizedRequestError();
if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) {
// raise alarm
throw UnauthorizedRequestError();
}
const response: {
refresh_token?: string;
access_token: string;
expires_in: number;
token_type: string;
} = {
refresh_token,
access_token: "",
expires_in: 0,
token_type: "Bearer"
};
if (serviceTokenData.isRefreshTokenRotationEnabled) {
serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
$inc: {
tokenVersion: 1
}
},
{
new: true
}
);
if (!serviceTokenData) throw BadRequestError();
response.refresh_token = createToken({
payload: {
serviceTokenDataId: serviceTokenData._id.toString(),
authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN,
tokenVersion: serviceTokenData.tokenVersion
},
secret: await getAuthSecret()
});
}
response.access_token = createToken({
payload: {
serviceTokenDataId: serviceTokenData._id.toString(),
authTokenType: AuthTokenType.SERVICE_ACCESS_TOKEN,
tokenVersion: serviceTokenData.tokenVersion
},
expiresIn: serviceTokenData.accessTokenTTL,
secret: await getAuthSecret()
});
response.expires_in = serviceTokenData.accessTokenTTL;
await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
refreshTokenLastUsed: new Date(),
$inc: { refreshTokenUsageCount: 1 }
},
{
new: true
}
);
return res.status(200).send(response);
}
/**
* Create service token data V3
* @param req
@ -71,8 +166,10 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
scopes,
trustedIps,
expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled,
encryptedKey, // for ServiceTokenDataV3Key
nonce // for ServiceTokenDataV3Key
nonce, // for ServiceTokenDataV3Key
}
} = await validateRequest(reqValidator.CreateServiceTokenV3, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
@ -118,11 +215,15 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
user,
workspace: new Types.ObjectId(workspaceId),
publicKey,
usageCount: 0,
refreshTokenUsageCount: 0,
accessTokenUsageCount: 0,
tokenVersion: 1,
trustedIps: reformattedTrustedIps,
scopes,
isActive,
expiresAt
expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}).save();
await new ServiceTokenDataV3Key({
@ -133,18 +234,19 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
workspace: new Types.ObjectId(workspaceId)
}).save();
const token = createToken({
const refreshToken = createToken({
payload: {
_id: serviceTokenData._id.toString()
serviceTokenDataId: serviceTokenData._id.toString(),
authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN,
tokenVersion: serviceTokenData.tokenVersion
},
expiresIn,
secret: await getJwtServiceTokenSecret()
secret: await getAuthSecret()
});
await EEAuditLogService.createAuditLog(
req.authData,
{
type: EventType.CREATE_SERVICE_TOKEN_V3,
type: EventType.CREATE_SERVICE_TOKEN_V3, // TODO: update
metadata: {
name,
isActive,
@ -160,7 +262,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
return res.status(200).send({
serviceTokenData,
serviceToken: `stv3.${token}`
refreshToken
});
}
@ -178,7 +280,9 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
isActive,
scopes,
trustedIps,
expiresIn
expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
}
} = await validateRequest(reqValidator.UpdateServiceTokenV3, req);
@ -233,13 +337,15 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
isActive,
scopes,
trustedIps: reformattedTrustedIps,
expiresAt
expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
},
{
new: true
}
);
if (!serviceTokenData) throw BadRequestError({
message: "Failed to update service token"
});

@ -7,11 +7,16 @@ import { serviceTokenDataController } from "../../controllers/v3";
router.get(
"/me/key",
requireAuth({
acceptedAuthModes: [AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.SERVICE_ACCESS_TOKEN]
}),
serviceTokenDataController.getServiceTokenDataKey
);
router.post(
"/me/token",
serviceTokenDataController.refreshToken
);
router.post(
"/",
requireAuth({

@ -1,381 +1,13 @@
import { Request } from "express";
import { Types } from "mongoose";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import {
APIKeyData,
APIKeyDataV2,
ITokenVersion,
IUser,
ServiceTokenData,
ServiceTokenDataV3,
TokenVersion,
User,
} from "../models";
import {
APIKeyDataNotFoundError,
AccountNotFoundError,
BadRequestError,
ServiceTokenDataNotFoundError,
UnauthorizedRequestError,
} from "../utils/errors";
import { ITokenVersion, TokenVersion } from "../models";
import { UnauthorizedRequestError } from "../utils/errors";
import {
getAuthSecret,
getJwtAuthLifetime,
getJwtRefreshLifetime,
getJwtServiceTokenSecret
getJwtRefreshLifetime
} from "../config";
import {
AuthMode,
AuthTokenType
} from "../variables";
import {
ServiceTokenAuthData,
ServiceTokenV3AuthData,
UserAuthData
} from "../interfaces/middleware";
import { ActorType } from "../ee/models";
import { getUserAgentType } from "../utils/posthog";
/**
*
* @param {Object} obj
* @param {Object} obj.headers - HTTP request headers object
*/
export const validateAuthMode = ({
headers,
acceptedAuthModes,
}: {
headers: { [key: string]: string | string[] | undefined },
acceptedAuthModes: AuthMode[]
}) => {
const apiKey = headers["x-api-key"];
const authHeader = headers["authorization"];
let authMode, authTokenValue;
if (apiKey === undefined && authHeader === undefined) {
// case: no auth or X-API-KEY header present
throw BadRequestError({ message: "Missing Authorization or X-API-KEY in request header." });
}
if (typeof apiKey === "string") {
// case: treat request authentication type as via X-API-KEY (i.e. API Key)
authMode = AuthMode.API_KEY;
authTokenValue = apiKey;
}
if (typeof authHeader === "string") {
// case: treat request authentication type as via Authorization header (i.e. either JWT or service token)
const [tokenType, tokenValue] = <[string, string]>authHeader.split(" ", 2) ?? [null, null]
if (tokenType === null)
throw BadRequestError({ message: "Missing Authorization Header in the request header." });
if (tokenType.toLowerCase() !== "bearer")
throw BadRequestError({ message: `The provided authentication type '${tokenType}' is not supported.` });
if (tokenValue === null)
throw BadRequestError({ message: "Missing Authorization Body in the request header." });
const parts = tokenValue.split(".");
switch (parts[0]) {
case "st":
authMode = AuthMode.SERVICE_TOKEN;
authTokenValue = tokenValue;
break;
case "stv3":
authMode = AuthMode.SERVICE_TOKEN_V3;
authTokenValue = parts.slice(1).join(".");
break;
default:
authMode = AuthMode.JWT;
authTokenValue = tokenValue;
}
}
if (!authMode || !authTokenValue) throw BadRequestError({ message: "Missing valid Authorization or X-API-KEY in request header." });
if (!acceptedAuthModes.includes(authMode)) throw BadRequestError({ message: "The provided authentication type is not supported." });
return ({
authMode,
authTokenValue,
});
}
/**
* Return user payload corresponding to JWT token [authTokenValue]
* that is either for browser / CLI or API Key
* @param {Object} obj
* @param {String} obj.authTokenValue - JWT token value
* @returns {User} user - user corresponding to JWT token
*/
export const getAuthUserPayload = async ({
req,
authTokenValue,
}: {
req: Request,
authTokenValue: string;
}): Promise<UserAuthData> => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
);
if (
decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN &&
decodedToken.authTokenType !== AuthTokenType.API_KEY
) {
throw UnauthorizedRequestError();
}
if (decodedToken.authTokenType === AuthTokenType.ACCESS_TOKEN) {
const tokenVersion = await TokenVersion.findOneAndUpdate({
_id: new Types.ObjectId(decodedToken.tokenVersionId),
user: decodedToken.userId
}, {
lastUsed: new Date(),
});
if (!tokenVersion) throw UnauthorizedRequestError();
if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError();
} else if (decodedToken.authTokenType === AuthTokenType.API_KEY) {
const apiKeyData = await APIKeyDataV2.findOneAndUpdate(
{
_id: new Types.ObjectId(decodedToken.apiKeyDataId),
user: new Types.ObjectId(decodedToken.userId)
},
{
lastUsed: new Date(),
$inc: { usageCount: 1 }
},
{
new: true
}
);
if (!apiKeyData) throw UnauthorizedRequestError();
}
const user = await User.findOne({
_id: new Types.ObjectId(decodedToken.userId),
}).select("+publicKey +accessVersion");
if (!user) throw AccountNotFoundError({ message: "Failed to find user" });
if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" });
return {
actor: {
type: ActorType.USER,
metadata: {
userId: user._id.toString(),
email: user.email
}
},
authPayload: user,
ipAddress: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
userAgentType: getUserAgentType(req.headers["user-agent"])
}
}
/**
* Return service token data payload corresponding to service token [authTokenValue]
* @param {Object} obj
* @param {String} obj.authTokenValue - service token value
* @returns {ServiceTokenData} serviceTokenData - service token data
*/
export const getAuthSTDPayload = async ({
req,
authTokenValue,
}: {
req: Request,
authTokenValue: string;
}): Promise<ServiceTokenAuthData> => {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3);
const serviceTokenData = await ServiceTokenData
.findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt")
if (!serviceTokenData) {
throw ServiceTokenDataNotFoundError({ message: "Failed to find service token data" });
} else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) {
// case: service token expired
await ServiceTokenData.findByIdAndDelete(serviceTokenData._id);
throw UnauthorizedRequestError({
message: "Failed to authenticate expired service token",
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: "Failed to authenticate service token",
});
const serviceTokenDataToReturn = await ServiceTokenData
.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER),
}, {
lastUsed: new Date(),
}, {
new: true,
})
.select("+encryptedKey +iv +tag")
if (!serviceTokenDataToReturn) throw ServiceTokenDataNotFoundError({ message: "Failed to find service token data" });
return {
actor: {
type: ActorType.SERVICE,
metadata: {
serviceId: serviceTokenDataToReturn._id.toString(),
name: serviceTokenDataToReturn.name
}
},
authPayload: serviceTokenDataToReturn,
ipAddress: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
userAgentType: getUserAgentType(req.headers["user-agent"])
}
}
/**
* Return service token data V3 payload corresponding to service token [authTokenValue]
* @param {Object} obj
* @param {String} obj.authTokenValue - service token value
* @returns {ServiceTokenData} serviceTokenData - service token data
*/
export const getAuthSTDV3Payload = async ({
req,
authTokenValue,
}: {
req: Request,
authTokenValue: string;
}): Promise<ServiceTokenV3AuthData> => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getJwtServiceTokenSecret())
);
const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate(
{
_id: new Types.ObjectId(decodedToken._id),
isActive: true
},
{
lastUsed: new Date(),
$inc: { usageCount: 1 }
},
{
new: true
}
);
if (!serviceTokenData) {
throw UnauthorizedRequestError({
message: "Failed to authenticate"
});
} else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) {
// case: service token expired
await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
isActive: false
},
{
new: true
}
);
throw UnauthorizedRequestError({
message: "Failed to authenticate",
});
}
return {
actor: {
type: ActorType.SERVICE_V3,
metadata: {
serviceId: serviceTokenData._id.toString(),
name: serviceTokenData.name
}
},
authPayload: serviceTokenData,
ipAddress: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
userAgentType: getUserAgentType(req.headers["user-agent"])
}
}
/**
* Return API key data payload corresponding to API key [authTokenValue]
* @param {Object} obj
* @param {String} obj.authTokenValue - API key value
* @returns {APIKeyData} apiKeyData - API key data
*/
export const getAuthAPIKeyPayload = async ({
req,
authTokenValue,
}: {
req: Request,
authTokenValue: string;
}): Promise<UserAuthData> => {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3);
let apiKeyData = await APIKeyData
.findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt")
.populate<{ user: IUser }>("user", "+publicKey");
if (!apiKeyData) {
throw APIKeyDataNotFoundError({ message: "Failed to find API key data" });
} else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) {
// case: API key expired
await APIKeyData.findByIdAndDelete(apiKeyData._id);
throw UnauthorizedRequestError({
message: "Failed to authenticate expired API key",
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: "Failed to authenticate API key",
});
apiKeyData = await APIKeyData.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER),
}, {
lastUsed: new Date(),
}, {
new: true,
});
if (!apiKeyData) {
throw APIKeyDataNotFoundError({ message: "Failed to find API key data" });
}
const user = await User.findById(apiKeyData.user).select("+publicKey");
if (!user) {
throw AccountNotFoundError({
message: "Failed to find user",
});
}
return {
actor: {
type: ActorType.USER,
metadata: {
userId: user._id.toString(),
email: user.email
}
},
authPayload: user,
ipAddress: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
userAgentType: getUserAgentType(req.headers["user-agent"])
}
}
import { AuthTokenType } from "../variables";
/**
* Return newly issued (JWT) auth and refresh tokens to user with id [userId]

@ -49,7 +49,7 @@ import {
import { TelemetryService } from "../services";
import { client, getEncryptionKey, getRootEncryptionKey } from "../config";
import { EEAuditLogService, EELogService, EESecretService } from "../ee/services";
import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/auth";
import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/authn/helpers";
import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService";
import picomatch from "picomatch";
import path from "path";

@ -1,14 +1,9 @@
import jwt from "jsonwebtoken";
import { NextFunction, Request, Response } from "express";
import {
getAuthAPIKeyPayload,
getAuthSTDPayload,
getAuthSTDV3Payload,
getAuthUserPayload,
validateAuthMode,
} from "../helpers/auth";
import { AuthMode } from "../variables";
import { AuthData } from "../interfaces/middleware";
import { extractAuthMode, getAuthData } from "../utils/authn/helpers";
import { UnauthorizedRequestError } from "../utils/errors";
declare module "jsonwebtoken" {
export interface UserIDJwtPayload extends jwt.JwtPayload {
@ -32,42 +27,39 @@ const requireAuth = ({
acceptedAuthModes: AuthMode[];
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
// extract auth mode
const { authMode, authTokenValue } = await extractAuthMode({
headers: req.headers
});
// validate auth token against accepted auth modes [acceptedAuthModes]
// and return token type [authTokenType] and value [authTokenValue]
const { authMode, authTokenValue } = validateAuthMode({
headers: req.headers,
acceptedAuthModes,
// validate auth mode
if (!acceptedAuthModes.includes(authMode)) throw UnauthorizedRequestError({
message: "Failed to authenticate unaccepted authentication mode"
});
let authData: AuthData;
// get auth data / payload
const authData: AuthData = await getAuthData({
authMode,
authTokenValue,
ipAddress: req.realIP,
userAgent: req.headers["user-agent"] ?? ""
});
switch (authMode) {
case AuthMode.SERVICE_TOKEN:
authData = await getAuthSTDPayload({
req,
authTokenValue,
});
req.serviceTokenData = authData.authPayload;
break;
case AuthMode.SERVICE_TOKEN_V3:
authData = await getAuthSTDV3Payload({
req,
authTokenValue
});
case AuthMode.SERVICE_ACCESS_TOKEN:
req.serviceTokenData = authData.authPayload;
break;
case AuthMode.API_KEY:
authData = await getAuthAPIKeyPayload({
req,
authTokenValue
});
req.user = authData.authPayload;
break;
case AuthMode.API_KEY_V2:
req.user = authData.authPayload;
break;
case AuthMode.JWT:
authData = await getAuthUserPayload({
req,
authTokenValue
});
req.user = authData.authPayload;
break;
}

@ -25,9 +25,14 @@ export interface IServiceTokenDataV3 extends Document {
user: Types.ObjectId;
publicKey: string;
isActive: boolean;
lastUsed?: Date;
usageCount: number;
refreshTokenLastUsed?: Date;
accessTokenLastUsed?: Date;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
tokenVersion: number;
isRefreshTokenRotationEnabled: boolean;
expiresAt?: Date;
accessTokenTTL: number;
scopes: Array<IServiceTokenV3Scope>;
trustedIps: Array<IServiceTokenV3TrustedIp>;
}
@ -57,19 +62,43 @@ const serviceTokenDataV3Schema = new Schema(
default: true,
required: true
},
lastUsed: {
refreshTokenLastUsed: {
type: Date,
required: false
},
usageCount: {
accessTokenLastUsed: {
type: Date,
required: false
},
refreshTokenUsageCount: {
type: Number,
default: 0,
required: true
},
accessTokenUsageCount: {
type: Number,
default: 0,
required: true
},
expiresAt: {
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
// expires: 0
},
accessTokenTTL: { // seconds
type: Number,
default: 7200,
required: true
},
scopes: {
type: [

@ -7,7 +7,7 @@ import { AuthMode } from "../../variables";
router.get(
"/raw",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
secretsController.getSecretsRaw
);
@ -15,7 +15,7 @@ router.get(
router.get(
"/raw/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "query"
@ -29,7 +29,7 @@ router.get(
router.post(
"/raw/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -43,7 +43,7 @@ router.post(
router.patch(
"/raw/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -57,7 +57,7 @@ router.patch(
router.delete(
"/raw/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -71,7 +71,7 @@ router.delete(
router.get(
"/",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "query"
@ -83,7 +83,7 @@ router.get(
router.post(
"/batch",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -94,7 +94,7 @@ router.post(
router.patch(
"/batch",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -105,7 +105,7 @@ router.patch(
router.delete(
"/batch",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -116,7 +116,7 @@ router.delete(
router.post(
"/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -127,7 +127,7 @@ router.post(
router.get(
"/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "query"
@ -138,7 +138,7 @@ router.get(
router.patch(
"/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"
@ -149,7 +149,7 @@ router.patch(
router.delete(
"/:secretName",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3]
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}),
requireBlindIndicesEnabled({
locationWorkspaceId: "body"

@ -1,435 +0,0 @@
import express from "express";
import passport from "passport";
import { Types } from "mongoose";
import { AuthData } from "../interfaces/middleware";
import {
AuthMethod,
MembershipOrg,
Organization,
ServiceAccount,
ServiceTokenData,
ServiceTokenDataV3,
User
} from "../models";
import { createToken } from "../helpers/auth";
import {
getAuthSecret,
getClientIdGitHubLogin,
getClientIdGitLabLogin,
getClientIdGoogleLogin,
getClientSecretGitHubLogin,
getClientSecretGitLabLogin,
getClientSecretGoogleLogin,
getJwtProviderAuthLifetime,
getSiteURL,
getUrlGitLabLogin
} from "../config";
import { getSSOConfigHelper } from "../ee/helpers/organizations";
import { InternalServerError, OrganizationNotFoundError } from "./errors";
import { ACCEPTED, AuthTokenType, INTEGRATION_GITHUB_API_URL, INVITED, MEMBER } from "../variables";
import { standardRequest } from "../config/request";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GoogleStrategy = require("passport-google-oauth20").Strategy;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GitHubStrategy = require("passport-github").Strategy;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GitLabStrategy = require("passport-gitlab2").Strategy;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MultiSamlStrategy } = require("@node-saml/passport-saml");
/**
* Returns an object containing the id of the authentication data payload
* @param {AuthData} authData - authentication data object
* @returns
*/
const getAuthDataPayloadIdObj = (authData: AuthData) => {
if (authData.authPayload instanceof User) {
return { userId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceAccount) {
return { serviceAccountId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceTokenData) {
return { serviceTokenDataId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceTokenDataV3) {
return { serviceTokenDataId: authData.authPayload._id };
}
};
/**
* Returns an object containing the user associated with the authentication data payload
* @param {AuthData} authData - authentication data object
* @returns
*/
const getAuthDataPayloadUserObj = (authData: AuthData) => {
if (authData.authPayload instanceof User) {
return { user: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceAccount) {
return { user: authData.authPayload.user };
}
if (authData.authPayload instanceof ServiceTokenData) {
return { user: authData.authPayload.user };
}
if (authData.authPayload instanceof ServiceTokenDataV3) {
return { user: authData.authPayload.user };
}
}
const initializePassport = async () => {
const clientIdGoogleLogin = await getClientIdGoogleLogin();
const clientSecretGoogleLogin = await getClientSecretGoogleLogin();
const clientIdGitHubLogin = await getClientIdGitHubLogin();
const clientSecretGitHubLogin = await getClientSecretGitHubLogin();
const urlGitLab = await getUrlGitLabLogin();
const clientIdGitLabLogin = await getClientIdGitLabLogin();
const clientSecretGitLabLogin = await getClientSecretGitLabLogin();
if (clientIdGoogleLogin && clientSecretGoogleLogin) {
passport.use(new GoogleStrategy({
passReqToCallback: true,
clientID: clientIdGoogleLogin,
clientSecret: clientSecretGoogleLogin,
callbackURL: "/api/v1/sso/google",
scope: ["profile", " email"],
}, async (
req: express.Request,
accessToken: string,
refreshToken: string,
profile: any,
done: any
) => {
try {
const email = profile.emails[0].value;
let user = await User.findOne({
email
}).select("+publicKey");
if (!user) {
user = await new User({
email,
authMethods: [AuthMethod.GOOGLE],
firstName: profile.name.givenName,
lastName: profile.name.familyName
}).save();
}
let isLinkingRequired = false;
if (!user.authMethods.includes(AuthMethod.GOOGLE)) {
isLinkingRequired = true;
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authMethod: AuthMethod.GOOGLE,
isUserCompleted,
isLinkingRequired,
...(req.query.state ? {
callbackPort: req.query.state as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
done(null, profile);
} catch (err) {
done(null, false);
}
}));
}
if (clientIdGitHubLogin && clientSecretGitHubLogin) {
passport.use(new GitHubStrategy({
passReqToCallback: true,
clientID: clientIdGitHubLogin,
clientSecret: clientSecretGitHubLogin,
callbackURL: "/api/v1/sso/github",
scope: ["user:email"]
},
async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => {
interface GitHubEmail {
email: string;
primary: boolean;
verified: boolean;
visibility: null | string;
}
const { data }: { data: GitHubEmail[] } = await standardRequest.get(
`${INTEGRATION_GITHUB_API_URL}/user/emails`,
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0];
const email = primaryEmail.email;
let user = await User.findOne({
email
}).select("+publicKey");
if (!user) {
user = await new User({
email: email,
authMethods: [AuthMethod.GITHUB],
firstName: profile.displayName,
lastName: ""
}).save();
}
let isLinkingRequired = false;
if (!user.authMethods.includes(AuthMethod.GITHUB)) {
isLinkingRequired = true;
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authMethod: AuthMethod.GITHUB,
isUserCompleted,
isLinkingRequired,
...(req.query.state ? {
callbackPort: req.query.state as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
return done(null, profile);
}
));
}
if (urlGitLab && clientIdGitLabLogin && clientSecretGitLabLogin) {
passport.use(new GitLabStrategy({
passReqToCallback: true,
clientID: clientIdGitLabLogin,
clientSecret: clientSecretGitLabLogin,
callbackURL: "/api/v1/sso/gitlab",
baseURL: urlGitLab
},
async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => {
const email = profile.emails[0].value;
let user = await User.findOne({
email
}).select("+publicKey");
if (!user) {
user = await new User({
email: email,
authMethods: [AuthMethod.GITLAB],
firstName: profile.displayName,
lastName: ""
}).save();
}
let isLinkingRequired = false;
if (!user.authMethods.includes(AuthMethod.GITLAB)) {
isLinkingRequired = true;
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authMethod: AuthMethod.GITLAB,
isUserCompleted,
isLinkingRequired,
...(req.query.state ? {
callbackPort: req.query.state as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
return done(null, profile);
}
));
}
passport.use("saml", new MultiSamlStrategy(
{
passReqToCallback: true,
getSamlOptions: async (req: any, done: any) => {
const { ssoIdentifier } = req.params;
const ssoConfig = await getSSOConfigHelper({
ssoConfigId: new Types.ObjectId(ssoIdentifier)
});
interface ISAMLConfig {
callbackUrl: string;
entryPoint: string;
issuer: string;
cert: string;
audience: string;
wantAuthnResponseSigned?: boolean;
}
const samlConfig: ISAMLConfig = ({
callbackUrl: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`,
entryPoint: ssoConfig.entryPoint,
issuer: ssoConfig.issuer,
cert: ssoConfig.cert,
audience: await getSiteURL()
});
if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) {
samlConfig.wantAuthnResponseSigned = false;
}
if (ssoConfig.authProvider.toString() === AuthMethod.AZURE_SAML.toString()) {
if (req.body.RelayState && JSON.parse(req.body.RelayState).spInitiated) {
samlConfig.audience = `spn:${ssoConfig.issuer}`;
}
}
req.ssoConfig = ssoConfig;
done(null, samlConfig);
},
},
async (req: any, profile: any, done: any) => {
if (!req.ssoConfig.isActive) return done(InternalServerError());
const organization = await Organization.findById(req.ssoConfig.organization);
if (!organization) return done(OrganizationNotFoundError());
const email = profile.email;
const firstName = profile.firstName;
const lastName = profile.lastName;
let user = await User.findOne({
email
}).select("+publicKey");
if (user) {
// if user does not have SAML enabled then update
const hasSamlEnabled = user.authMethods
.some(
(authMethod: AuthMethod) => [
AuthMethod.OKTA_SAML,
AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (!hasSamlEnabled) {
await User.findByIdAndUpdate(
user._id,
{
authMethods: [req.ssoConfig.authProvider]
},
{
new: true
}
);
}
let membershipOrg = await MembershipOrg.findOne(
{
user: user._id,
organization: organization._id
}
);
if (!membershipOrg) {
membershipOrg = await new MembershipOrg({
inviteEmail: email,
user: user._id,
organization: organization._id,
role: MEMBER,
status: ACCEPTED
}).save();
}
if (membershipOrg.status === INVITED) {
membershipOrg.status = ACCEPTED;
await membershipOrg.save();
}
} else {
user = await new User({
email,
authMethods: [req.ssoConfig.authProvider],
firstName,
lastName
}).save();
await new MembershipOrg({
inviteEmail: email,
user: user._id,
organization: organization._id,
role: MEMBER,
status: INVITED
}).save();
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName,
lastName,
organizationName: organization?.name,
authMethod: req.ssoConfig.authProvider,
isUserCompleted,
...(req.body.RelayState ? {
callbackPort: JSON.parse(req.body.RelayState).callbackPort as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
done(null, profile);
}
));
}
export {
getAuthDataPayloadIdObj,
getAuthDataPayloadUserObj,
initializePassport,
}

@ -0,0 +1,50 @@
import { Types } from "mongoose";
import {
APIKeyData,
IUser,
User
} from "../../../models";
import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors";
import bcrypt from "bcrypt";
interface ValidateAPIKeyParams {
authTokenValue: string;
}
export const validateAPIKey = async ({
authTokenValue
}: ValidateAPIKeyParams) => {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3);
let apiKeyData = await APIKeyData
.findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt")
.populate<{ user: IUser }>("user", "+publicKey");
if (!apiKeyData) {
throw UnauthorizedRequestError();
} else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) {
// case: API key expired
await APIKeyData.findByIdAndDelete(apiKeyData._id);
throw UnauthorizedRequestError();
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash);
if (!isMatch) throw UnauthorizedRequestError();
apiKeyData = await APIKeyData.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER),
}, {
lastUsed: new Date(),
}, {
new: true,
});
if (!apiKeyData) throw UnauthorizedRequestError();
const user = await User.findById(apiKeyData.user).select("+publicKey");
if (!user) throw AccountNotFoundError();
return user;
}

@ -0,0 +1,39 @@
import jwt from "jsonwebtoken";
import { APIKeyDataV2, User } from "../../../models";
import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables";
import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors";
interface ValidateAPIKeyV2Params {
authTokenValue: string;
}
export const validateAPIKeyV2 = async ({
authTokenValue
}: ValidateAPIKeyV2Params) => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
);
if (decodedToken.authTokenType !== AuthTokenType.API_KEY) throw UnauthorizedRequestError();
const apiKeyData = await APIKeyDataV2.findByIdAndUpdate(
decodedToken.apiKeyDataId,
{
lastUsed: new Date(),
$inc: { usageCount: 1 }
},
{
new: true
}
);
if (!apiKeyData) throw UnauthorizedRequestError();
const user = await User.findById(apiKeyData.user).select("+publicKey");
if (!user) throw AccountNotFoundError();
return user;
}

@ -0,0 +1,5 @@
export * from "./apiKey";
export * from "./apiKeyV2";
export * from "./jwt";
export * from "./serviceTokenV2";
export * from "./serviceTokenV3";

@ -0,0 +1,41 @@
import jwt from "jsonwebtoken";
import { Types } from "mongoose";
import { TokenVersion, User } from "../../../models";
import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables";
import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors";
interface ValidateJWTParams {
authTokenValue: string;
}
export const validateJWT = async ({
authTokenValue
}: ValidateJWTParams) => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
);
if (decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN) throw UnauthorizedRequestError();
const tokenVersion = await TokenVersion.findOneAndUpdate({
_id: new Types.ObjectId(decodedToken.tokenVersionId),
user: decodedToken.userId
}, {
lastUsed: new Date(),
});
if (!tokenVersion) throw UnauthorizedRequestError();
if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError();
const user = await User.findOne({
_id: new Types.ObjectId(decodedToken.userId),
}).select("+publicKey");
if (!user) throw AccountNotFoundError({ message: "Failed to find user" });
if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" });
return user;
}

@ -0,0 +1,44 @@
import { Types } from "mongoose";
import { ServiceTokenData } from "../../../models";
import { ResourceNotFoundError, UnauthorizedRequestError } from "../../errors";
import bcrypt from "bcrypt";
interface ValidateServiceTokenV2Params {
authTokenValue: string;
}
export const validateServiceTokenV2 = async ({
authTokenValue
}: ValidateServiceTokenV2Params) => {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3);
const serviceTokenData = await ServiceTokenData
.findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt")
if (!serviceTokenData) {
throw UnauthorizedRequestError();
} else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) {
// case: service token expired
await ServiceTokenData.findByIdAndDelete(serviceTokenData._id);
throw UnauthorizedRequestError({
message: "Failed to authenticate expired service token",
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash);
if (!isMatch) throw UnauthorizedRequestError();
const serviceTokenDataToReturn = await ServiceTokenData
.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER),
}, {
lastUsed: new Date(),
}, {
new: true,
})
.select("+encryptedKey +iv +tag")
if (!serviceTokenDataToReturn) throw ResourceNotFoundError();
return serviceTokenDataToReturn;
}

@ -0,0 +1,64 @@
import jwt from "jsonwebtoken";
import { Types } from "mongoose";
import { ServiceTokenDataV3 } from "../../../models";
import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables";
import { UnauthorizedRequestError } from "../../errors";
interface ValidateServiceTokenV3Params {
authTokenValue: string;
}
export const validateServiceTokenV3 = async ({
authTokenValue
}: ValidateServiceTokenV3Params) => {
const decodedToken = <jwt.ServiceRefreshTokenJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
);
if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError();
const serviceTokenData = await ServiceTokenDataV3.findOne({
_id: new Types.ObjectId(decodedToken.serviceTokenDataId),
isActive: true
});
if (!serviceTokenData) {
throw UnauthorizedRequestError({
message: "Failed to authenticate"
});
} else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) {
// case: service token expired
await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
isActive: false
},
{
new: true
}
);
throw UnauthorizedRequestError({
message: "Failed to authenticate",
});
} else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) {
// TODO: raise alarm
throw UnauthorizedRequestError({
message: "Failed to authenticate",
});
}
await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
accessTokenLastUsed: new Date(),
$inc: { accessTokenUsageCount: 1 }
},
{
new: true
}
);
return serviceTokenData;
}

@ -0,0 +1,53 @@
import { AuthData } from "../../../interfaces/middleware";
import {
ServiceAccount,
ServiceTokenData,
ServiceTokenDataV3,
User
} from "../../../models";
/**
* Returns an object containing the id of the authentication data payload
* @param {AuthData} authData - authentication data object
* @returns
*/
export const getAuthDataPayloadIdObj = (authData: AuthData) => {
if (authData.authPayload instanceof User) {
return { userId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceAccount) {
return { serviceAccountId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceTokenData) {
return { serviceTokenDataId: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceTokenDataV3) {
return { serviceTokenDataId: authData.authPayload._id };
}
};
/**
* Returns an object containing the user associated with the authentication data payload
* @param {AuthData} authData - authentication data object
* @returns
*/
export const getAuthDataPayloadUserObj = (authData: AuthData) => {
if (authData.authPayload instanceof User) {
return { user: authData.authPayload._id };
}
if (authData.authPayload instanceof ServiceAccount) {
return { user: authData.authPayload.user };
}
if (authData.authPayload instanceof ServiceTokenData) {
return { user: authData.authPayload.user };
}
if (authData.authPayload instanceof ServiceTokenDataV3) {
return { user: authData.authPayload.user };
}
}

@ -0,0 +1,195 @@
import { AuthData } from "../../../interfaces/middleware";
import jwt from "jsonwebtoken";
import { getAuthSecret } from "../../../config";
import { ActorType } from "../../../ee/models";
import { AuthMode, AuthTokenType } from "../../../variables";
import { UnauthorizedRequestError } from "../../errors";
import {
validateAPIKey,
validateAPIKeyV2,
validateJWT,
validateServiceTokenV2,
validateServiceTokenV3
} from "../authModeValidators";
import { getUserAgentType } from "../../posthog";
export * from "./authDataExtractors";
interface ExtractAuthModeParams {
headers: { [key: string]: string | string[] | undefined }
}
interface ExtractAuthModeReturn {
authMode: AuthMode;
authTokenValue: string;
}
interface GetAuthDataParams {
authMode: AuthMode;
authTokenValue: string;
ipAddress: string;
userAgent: string;
}
/**
* Returns the recognized authentication mode based on token in [headers]; accepted token types include:
* - SERVICE_TOKEN
* - API_KEY
* - JWT
* - SERVICE_ACCESS_TOKEN (from ST V3)
* - API_KEY_V2
* @param {Object} params
* @param {Object.<string, (string|string[]|undefined)>} params.headers - The HTTP request headers, usually from Express's `req.headers`.
* @returns {Promise<AuthMode>} The derived authentication mode based on the headers.
* @throws {UnauthorizedError} Throws an error if no applicable authMode is found.
*/
export const extractAuthMode = async ({
headers
}: ExtractAuthModeParams): Promise<ExtractAuthModeReturn> => {
const apiKey = headers["x-api-key"] as string;
const authHeader = headers["authorization"] as string;
if (apiKey) {
return { authMode: AuthMode.API_KEY, authTokenValue: apiKey };
}
if (!authHeader) throw UnauthorizedRequestError({
message: "Failed to authenticate unknown authentication method"
});
if (!authHeader.startsWith("Bearer ")) throw UnauthorizedRequestError({
message: "Failed to authenticate unknown authentication method"
});
const authTokenValue = authHeader.slice(7);
if (authTokenValue.startsWith("st.")) {
return { authMode: AuthMode.SERVICE_TOKEN, authTokenValue };
}
const decodedToken = <jwt.AuthnJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
);
switch (decodedToken.authTokenType) {
case AuthTokenType.ACCESS_TOKEN:
return { authMode: AuthMode.JWT, authTokenValue };
case AuthTokenType.API_KEY:
return { authMode: AuthMode.API_KEY_V2, authTokenValue };
case AuthTokenType.SERVICE_ACCESS_TOKEN:
return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, authTokenValue };
default:
throw UnauthorizedRequestError({
message: "Failed to authenticate unknown authentication method"
});
}
}
export const getAuthData = async ({
authMode,
authTokenValue,
ipAddress,
userAgent
}: GetAuthDataParams): Promise<AuthData> => {
const userAgentType = getUserAgentType(userAgent);
switch (authMode) {
case AuthMode.SERVICE_TOKEN: {
const serviceTokenData = await validateServiceTokenV2({
authTokenValue
});
return {
actor: {
type: ActorType.SERVICE,
metadata: {
serviceId: serviceTokenData._id.toString(),
name: serviceTokenData.name
}
},
authPayload: serviceTokenData,
ipAddress,
userAgent,
userAgentType
}
}
case AuthMode.SERVICE_ACCESS_TOKEN: {
const serviceTokenData = await validateServiceTokenV3({
authTokenValue
});
return {
actor: {
type: ActorType.SERVICE_V3,
metadata: {
serviceId: serviceTokenData._id.toString(),
name: serviceTokenData.name
}
},
authPayload: serviceTokenData,
ipAddress,
userAgent,
userAgentType
}
}
case AuthMode.API_KEY: {
const user = await validateAPIKey({
authTokenValue
});
return {
actor: {
type: ActorType.USER,
metadata: {
userId: user._id.toString(),
email: user.email
}
},
authPayload: user,
ipAddress,
userAgent,
userAgentType
}
}
case AuthMode.API_KEY_V2: {
const user = await validateAPIKeyV2({
authTokenValue
});
return {
actor: {
type: ActorType.USER,
metadata: {
userId: user._id.toString(),
email: user.email
}
},
authPayload: user,
ipAddress,
userAgent,
userAgentType
}
}
case AuthMode.JWT: {
const user = await validateJWT({
authTokenValue
});
return {
actor: {
type: ActorType.USER,
metadata: {
userId: user._id.toString(),
email: user.email
}
},
authPayload: user,
ipAddress,
userAgent,
userAgentType
}
}
}
}

@ -0,0 +1,60 @@
import express from "express";
import passport from "passport";
import {
getClientIdGitHubLogin,
getClientSecretGitHubLogin,
} from "../../../config";
import { standardRequest } from "../../../config/request";
import { AuthMethod } from "../../../models";
import { INTEGRATION_GITHUB_API_URL } from "../../../variables";
import { handleSSOUserTokenFlow } from "./helpers";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GitHubStrategy = require("passport-github").Strategy;
export const initializeGitHubStrategy = async () => {
const clientIdGitHubLogin = await getClientIdGitHubLogin();
const clientSecretGitHubLogin = await getClientSecretGitHubLogin();
if (clientIdGitHubLogin && clientSecretGitHubLogin) {
passport.use(
new GitHubStrategy({
passReqToCallback: true,
clientID: clientIdGitHubLogin,
clientSecret: clientSecretGitHubLogin,
callbackURL: "/api/v1/sso/github",
scope: ["user:email"]
}, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => {
interface GitHubEmail {
email: string;
primary: boolean;
verified: boolean;
visibility: null | string;
}
const { data }: { data: GitHubEmail[] } = await standardRequest.get(
`${INTEGRATION_GITHUB_API_URL}/user/emails`,
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0];
const email = primaryEmail.email;
const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({
email,
firstName: profile.displayName,
lastName: "",
authMethod: AuthMethod.GITHUB,
callbackPort: req.query.state as string
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
return done(null, profile);
})
);
}
}

@ -0,0 +1,44 @@
import express from "express";
import passport from "passport";
import {
getClientIdGitLabLogin,
getClientSecretGitLabLogin,
getUrlGitLabLogin
} from "../../../config";
import { AuthMethod } from "../../../models";
import { handleSSOUserTokenFlow } from "./helpers";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GitLabStrategy = require("passport-gitlab2").Strategy;
export const initializeGitLabStrategy = async () => {
const urlGitLab = await getUrlGitLabLogin();
const clientIdGitLabLogin = await getClientIdGitLabLogin();
const clientSecretGitLabLogin = await getClientSecretGitLabLogin();
if (urlGitLab && clientIdGitLabLogin && clientSecretGitLabLogin) {
passport.use(
new GitLabStrategy({
passReqToCallback: true,
clientID: clientIdGitLabLogin,
clientSecret: clientSecretGitLabLogin,
callbackURL: "/api/v1/sso/gitlab",
baseURL: urlGitLab
}, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => {
const email = profile.emails[0].value;
const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({
email,
firstName: profile.displayName,
lastName: "",
authMethod: AuthMethod.GITLAB,
callbackPort: req.query.state as string
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
return done(null, profile);
})
);
}
}

@ -0,0 +1,48 @@
import express from "express";
import passport from "passport";
import { getClientIdGoogleLogin, getClientSecretGoogleLogin } from "../../../config";
import { AuthMethod } from "../../../models";
import { handleSSOUserTokenFlow } from "./helpers";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const GoogleStrategy = require("passport-google-oauth20").Strategy;
export const initializeGoogleStrategy = async () => {
const clientIdGoogleLogin = await getClientIdGoogleLogin();
const clientSecretGoogleLogin = await getClientSecretGoogleLogin();
if (clientIdGoogleLogin && clientSecretGoogleLogin) {
passport.use(new GoogleStrategy({
passReqToCallback: true,
clientID: clientIdGoogleLogin,
clientSecret: clientSecretGoogleLogin,
callbackURL: "/api/v1/sso/google",
scope: ["profile", " email"],
}, async (
req: express.Request,
accessToken: string,
refreshToken: string,
profile: any,
done: any
) => {
try {
const email = profile.emails[0].value;
const { isUserCompleted, providerAuthToken } = await handleSSOUserTokenFlow({
email,
firstName: profile.name.givenName,
lastName: profile.name.familyName,
authMethod: AuthMethod.GOOGLE,
callbackPort: req.query.state as string
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
done(null, profile);
} catch (err) {
done(null, false);
}
}));
}
}

@ -0,0 +1,62 @@
import {
AuthMethod,
User
} from "../../../models";
import { createToken } from "../../../helpers/auth";
import { AuthTokenType } from "../../../variables";
import { getAuthSecret, getJwtProviderAuthLifetime} from "../../../config";
interface SSOUserTokenFlowParams {
email: string;
firstName: string;
lastName: string;
authMethod: AuthMethod;
callbackPort?: string;
}
export const handleSSOUserTokenFlow = async ({
email,
firstName,
lastName,
authMethod,
callbackPort
}: SSOUserTokenFlowParams) => {
let user = await User.findOne({
email
}).select("+publicKey");
if (!user) {
user = await new User({
email,
authMethods: [authMethod],
firstName,
lastName
}).save();
}
let isLinkingRequired = false;
if (!user.authMethods.includes(authMethod)) {
isLinkingRequired = true;
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authMethod,
isUserCompleted,
isLinkingRequired,
...(callbackPort ? {
callbackPort
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
return { isUserCompleted, providerAuthToken };
}

@ -0,0 +1,4 @@
export { initializeGoogleStrategy } from "./google";
export { initializeGitHubStrategy } from "./github";
export { initializeGitLabStrategy } from "./gitlab";
export { initializeSamlStrategy } from "./saml";

@ -0,0 +1,173 @@
import passport from "passport";
import {
getAuthSecret,
getJwtProviderAuthLifetime,
getSiteURL
} from "../../../config";
import {
AuthMethod,
MembershipOrg,
Organization,
User
} from "../../../models";
import {
createToken
} from "../../../helpers/auth";
import {
ACCEPTED,
AuthTokenType,
INVITED,
MEMBER
} from "../../../variables";
import { Types } from "mongoose";
import { getSSOConfigHelper } from "../../../ee/helpers/organizations";
import { InternalServerError, OrganizationNotFoundError } from "../../errors";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MultiSamlStrategy } = require("@node-saml/passport-saml");
export const initializeSamlStrategy = async () => {
passport.use("saml", new MultiSamlStrategy(
{
passReqToCallback: true,
getSamlOptions: async (req: any, done: any) => {
const { ssoIdentifier } = req.params;
const ssoConfig = await getSSOConfigHelper({
ssoConfigId: new Types.ObjectId(ssoIdentifier)
});
interface ISAMLConfig {
callbackUrl: string;
entryPoint: string;
issuer: string;
cert: string;
audience: string;
wantAuthnResponseSigned?: boolean;
}
const samlConfig: ISAMLConfig = ({
callbackUrl: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`,
entryPoint: ssoConfig.entryPoint,
issuer: ssoConfig.issuer,
cert: ssoConfig.cert,
audience: await getSiteURL()
});
if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) {
samlConfig.wantAuthnResponseSigned = false;
}
if (ssoConfig.authProvider.toString() === AuthMethod.AZURE_SAML.toString()) {
if (req.body.RelayState && JSON.parse(req.body.RelayState).spInitiated) {
samlConfig.audience = `spn:${ssoConfig.issuer}`;
}
}
req.ssoConfig = ssoConfig;
done(null, samlConfig);
},
},
async (req: any, profile: any, done: any) => {
if (!req.ssoConfig.isActive) return done(InternalServerError());
const organization = await Organization.findById(req.ssoConfig.organization);
if (!organization) return done(OrganizationNotFoundError());
const email = profile.email;
const firstName = profile.firstName;
const lastName = profile.lastName;
let user = await User.findOne({
email
}).select("+publicKey");
if (user) {
// if user does not have SAML enabled then update
const hasSamlEnabled = user.authMethods
.some(
(authMethod: AuthMethod) => [
AuthMethod.OKTA_SAML,
AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (!hasSamlEnabled) {
await User.findByIdAndUpdate(
user._id,
{
authMethods: [req.ssoConfig.authProvider]
},
{
new: true
}
);
}
let membershipOrg = await MembershipOrg.findOne(
{
user: user._id,
organization: organization._id
}
);
if (!membershipOrg) {
membershipOrg = await new MembershipOrg({
inviteEmail: email,
user: user._id,
organization: organization._id,
role: MEMBER,
status: ACCEPTED
}).save();
}
if (membershipOrg.status === INVITED) {
membershipOrg.status = ACCEPTED;
await membershipOrg.save();
}
} else {
user = await new User({
email,
authMethods: [req.ssoConfig.authProvider],
firstName,
lastName
}).save();
await new MembershipOrg({
inviteEmail: email,
user: user._id,
organization: organization._id,
role: MEMBER,
status: INVITED
}).save();
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user._id.toString(),
email: user.email,
firstName,
lastName,
organizationName: organization?.name,
authMethod: req.ssoConfig.authProvider,
isUserCompleted,
...(req.body.RelayState ? {
callbackPort: JSON.parse(req.body.RelayState).callbackPort as string
} : {})
},
expiresIn: await getJwtProviderAuthLifetime(),
secret: await getAuthSecret(),
});
req.isUserCompleted = isUserCompleted;
req.providerAuthToken = providerAuthToken;
done(null, profile);
}
));
}

@ -26,7 +26,12 @@ import {
reencryptSecretBlindIndexDataSalts
} from "./reencryptData";
import { getMongoURL, getNodeEnv, getRedisUrl, getSentryDSN } from "../../config";
import { initializePassport } from "../auth";
import {
initializeGitHubStrategy,
initializeGitLabStrategy,
initializeGoogleStrategy,
initializeSamlStrategy
} from "../authn/passport";
import { logger } from "../logging";
/**
@ -55,7 +60,11 @@ export const setup = async () => {
// initializing global feature set
await EELicenseService.initGlobalFeatureSet();
await initializePassport();
// initializing auth strategies
await initializeGoogleStrategy();
await initializeGitHubStrategy()
await initializeGitLabStrategy();
await initializeSamlStrategy();
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
// to base64 256-bit ROOT_ENCRYPTION_KEY

@ -60,6 +60,12 @@ import { checkIPAgainstBlocklist } from "../utils/ip";
}
};
export const RefreshTokenV3 = z.object({
body: z.object({
refresh_token: z.string().trim()
})
});
export const CreateServiceTokenV3 = z.object({
body: z.object({
name: z.string().trim(),
@ -80,8 +86,10 @@ export const CreateServiceTokenV3 = z.object({
.array()
.min(1),
expiresIn: z.number().optional(),
accessTokenTTL: z.number().int().min(1),
encryptedKey: z.string().trim(),
nonce: z.string().trim()
nonce: z.string().trim(),
isRefreshTokenRotationEnabled: z.boolean().default(false)
})
});
@ -108,7 +116,9 @@ export const UpdateServiceTokenV3 = z.object({
.array()
.min(1)
.optional(),
expiresIn: z.number().optional()
expiresIn: z.number().optional(),
accessTokenTTL: z.number().int().min(1).optional(),
isRefreshTokenRotationEnabled: z.boolean().optional()
}),
});

@ -1,17 +1,22 @@
// TODO: merge [AuthTokenType] and [AuthMode]
export enum AuthTokenType {
ACCESS_TOKEN = "accessToken",
REFRESH_TOKEN = "refreshToken",
SIGNUP_TOKEN = "signupToken",
MFA_TOKEN = "mfaToken",
PROVIDER_TOKEN = "providerToken",
API_KEY = "apiKey"
SIGNUP_TOKEN = "signupToken", // TODO: remove in favor of claim
MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim
PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim
API_KEY = "apiKey",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
SERVICE_REFRESH_TOKEN = "serviceRefreshToken"
}
export enum AuthMode {
JWT = "jwt",
SERVICE_TOKEN = "serviceToken",
SERVICE_TOKEN_V3 = "serviceTokenV3",
API_KEY = "apiKey"
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
API_KEY = "apiKey",
API_KEY_V2 = "apiKeyV2"
}
export const K8_USER_AGENT_NAME = "k8-operator"

@ -88,14 +88,18 @@ export const useUpdateServiceTokenV3 = () => {
isActive,
scopes,
trustedIps,
expiresIn
expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => {
const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, {
name,
isActive,
scopes,
trustedIps,
expiresIn
expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
});
return serviceTokenData;

@ -56,11 +56,15 @@ export type ServiceTokenDataV3 = {
name: string;
workspace: string;
isActive: boolean;
lastUsed?: string;
usageCount: number;
refreshTokenLastUsed?: string;
accessTokenLastUsed?: string;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[];
expiresAt?: string;
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
createdAt: string;
updatedAt: string;
};
@ -74,12 +78,14 @@ export type CreateServiceTokenDataV3DTO = {
ipAddress: string;
}[];
expiresIn?: number;
accessTokenTTL: number;
encryptedKey: string;
nonce: string;
isRefreshTokenRotationEnabled: boolean;
}
export type CreateServiceTokenDataV3Res = {
serviceToken: string;
refreshToken: string;
serviceTokenData: ServiceTokenDataV3;
}
@ -92,6 +98,8 @@ export type UpdateServiceTokenDataV3DTO = {
ipAddress: string;
}[];
expiresIn?: number;
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
}
export type DeleteServiceTokenDataV3DTO = {

@ -1,5 +1,5 @@
// import { APIKeyV2Section } from "../APIKeyV2Section";
import { APIKeySection } from "../APIKeySection";
// import { APIKeyV2Section } from "../APIKeyV2Section";
export const PersonalAPIKeyTab = () => {
return (

@ -1,8 +1,9 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus,faXmark } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faCopy,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";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import * as yup from "yup";
@ -21,12 +22,17 @@ import {
ModalContent,
Select,
SelectItem,
UpgradePlanModal
} from "@app/components/v2";
Switch,
Tab,
TabList,
TabPanel,
Tabs,
UpgradePlanModal} from "@app/components/v2";
import {
useSubscription,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateServiceTokenV3,
useGetUserWsKey,
@ -41,8 +47,13 @@ import {
} from "@app/hooks/api/serviceTokens/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum TabSections {
General = "general",
Advanced = "advanced"
}
const expirations = [
{ label: "Never", value: undefined },
{ label: "Never", value: "" },
{ label: "1 day", value: "86400" },
{ label: "7 days", value: "604800" },
{ label: "1 month", value: "2592000" },
@ -60,6 +71,17 @@ const permissionsMap: {
const schema = yup.object({
name: yup.string().required("ST V3 name is required"),
expiresIn: yup.string(),
accessTokenTTL: yup
.string()
.test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => {
if (typeof value === "undefined") {
return false;
}
const num = parseInt(value, 10);
return !Number.isNaN(num) && num > 0 && String(num) === value;
})
.required("Access Token TTL is required"),
scopes: yup
.array(
yup.object({
@ -79,14 +101,15 @@ const schema = yup.object({
.required()
.label("Scope"),
trustedIps: yup
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
})
)
.min(1)
.required()
.label("Trusted IP")
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
})
)
.min(1)
.required()
.label("Trusted IP"),
isRefreshTokenRotationEnabled: yup.boolean().default(false)
}).required();
export type FormData = yup.InferType<typeof schema>;
@ -102,6 +125,9 @@ export const AddServiceTokenV3Modal = ({
handlePopUpOpen,
handlePopUpToggle
}: Props) => {
const [newServiceTokenJSON, setNewServiceTokenJSON] = useState("");
const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false);
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
@ -118,6 +144,7 @@ export const AddServiceTokenV3Modal = ({
resolver: yupResolver(schema),
defaultValues: {
name: "",
accessTokenTTL: "7200",
scopes: [{
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug,
@ -128,6 +155,21 @@ export const AddServiceTokenV3Modal = ({
}]
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isServiceTokenJSONCopied) {
timer = setTimeout(() => setIsServiceTokenJSONCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [setIsServiceTokenJSONCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newServiceTokenJSON);
setIsServiceTokenJSONCopied.on();
};
useEffect(() => {
const serviceTokenData = popUp?.serviceTokenV3?.data as {
@ -135,6 +177,8 @@ export const AddServiceTokenV3Modal = ({
name: string;
scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[];
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
};
if (serviceTokenData) {
@ -163,11 +207,14 @@ export const AddServiceTokenV3Modal = ({
return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
})
}),
accessTokenTTL: String(serviceTokenData.accessTokenTTL),
isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled
});
} else {
reset({
name: "",
accessTokenTTL: "7200",
scopes: [{
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug,
@ -186,8 +233,10 @@ export const AddServiceTokenV3Modal = ({
const onFormSubmit = async ({
name,
expiresIn,
accessTokenTTL,
scopes,
trustedIps
trustedIps,
isRefreshTokenRotationEnabled
}: FormData) => {
try {
const serviceTokenData = popUp?.serviceTokenV3?.data as {
@ -213,8 +262,12 @@ export const AddServiceTokenV3Modal = ({
name,
scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn)
expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
});
handlePopUpToggle("serviceTokenV3", false);
} else {
// create
if (!currentWorkspace?._id) return;
@ -239,24 +292,29 @@ export const AddServiceTokenV3Modal = ({
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const { serviceToken } = await createMutateAsync({
const { refreshToken } = await createMutateAsync({
name,
workspaceId: currentWorkspace._id,
publicKey,
scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
encryptedKey: ciphertext,
nonce
nonce,
isRefreshTokenRotationEnabled
});
const downloadData = {
publicKey,
privateKey,
serviceToken
public_key: publicKey,
private_key: privateKey,
refresh_token: refreshToken
};
const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" });
const serviceTokenJSON = JSON.stringify(downloadData, null, 2);
setNewServiceTokenJSON(serviceTokenJSON);
const blob = new Blob([serviceTokenJSON], { type: "application/json" });
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
@ -272,7 +330,6 @@ export const AddServiceTokenV3Modal = ({
});
reset();
handlePopUpToggle("serviceTokenV3", false);
} catch (err) {
console.error(err);
createNotification({
@ -281,6 +338,8 @@ export const AddServiceTokenV3Modal = ({
});
}
}
const hasServiceTokenJSON = Boolean(newServiceTokenJSON);
return (
<Modal
@ -288,232 +347,305 @@ export const AddServiceTokenV3Modal = ({
onOpenChange={(isOpen) => {
handlePopUpToggle("serviceTokenV3", isOpen);
reset();
setNewServiceTokenJSON("");
}}
>
<ModalContent title={`${popUp?.serviceTokenV3?.data ? "Update" : "Create"} Service Token V3`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="My ST V3"
/>
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`scopes.${index}.permission`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Permission" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
<SelectItem value="read" key="st-v3-read">
Read
</SelectItem>
<SelectItem value="readWrite" key="st-v3-write">
Read &amp; Write
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.environment`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
onClick={() => remove(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
{!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 }}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: "/"
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
{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}
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="My ST V3"
/>
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`scopes.${index}.permission`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Permission" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
<SelectItem value="read" key="st-v3-read">
Read
</SelectItem>
<SelectItem value="readWrite" key="st-v3-write">
Read &amp; Write
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.environment`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
onClick={() => remove(index)}
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={() =>
append({
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: "/"
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
<Input
value={field.value}
onChange={(e) => {
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.serviceTokenV3?.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>
)}
/>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Advanced}>
<div>
{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) {
field.onChange(e);
appendTrustedIp({
ipAddress: "0.0.0.0/0"
})
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
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="7200"
/>
</FormControl>
)}
/>
<div className="mt-8">
<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>
)}
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
<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>
</div>
</div>
</TabPanel>
</Tabs>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
{popUp?.serviceTokenV3?.data ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</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"
>
Add IP Address
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue="15552000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Expire 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>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
</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}
>
{popUp?.serviceTokenV3?.data ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
<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>
</div>
</form>
)}
<UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}

@ -34,6 +34,8 @@ type Props = {
name?: string;
scopes?: ServiceTokenV3Scope[];
trustedIps?: ServiceTokenV3TrustedIp[];
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
}
) => void;
};
@ -81,10 +83,9 @@ export const ServiceTokenV3Table = ({
<Th>Status</Th>
<Th>Scopes</Th>
<Th>Trusted IPs</Th>
{/* <Th># Times Used</Th> */}
<Th>Last Used</Th>
<Th>Access Token TTL</Th>
<Th>Created At</Th>
<Th>Expires At</Th>
<Th>Valid Until</Th>
<Th className="w-5" />
</Tr>
</THead>
@ -97,12 +98,12 @@ export const ServiceTokenV3Table = ({
_id,
name,
isActive,
lastUsed,
// usageCount,
scopes,
trustedIps,
createdAt,
expiresAt
expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => {
return (
<Tr className="h-10" key={`st-v3-${_id}`}>
@ -160,8 +161,7 @@ export const ServiceTokenV3Table = ({
);
})}
</Td>
{/* <Td>{usageCount}</Td> */}
<Td>{lastUsed ? format(new Date(lastUsed), "yyyy-MM-dd") : "-"}</Td>
<Td>{accessTokenTTL}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td>
<Td className="flex justify-end">
@ -176,7 +176,9 @@ export const ServiceTokenV3Table = ({
serviceTokenDataId: _id,
name,
scopes,
trustedIps
trustedIps,
accessTokenTTL,
isRefreshTokenRotationEnabled
});
}}
size="lg"

Loading…
Cancel
Save