Update ST V3 impl to (rotating) refresh token impl

This commit is contained in:
Tuan Dang
2023-10-26 09:57:59 +01:00
parent fc3db93f8b
commit 65afaa8177
14 changed files with 337 additions and 97 deletions
-1
View File
@@ -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 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 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 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 getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue;
export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production"; export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production";
export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true;
@@ -28,6 +28,11 @@ declare module "jsonwebtoken" {
userId: string; userId: string;
refreshVersion?: number; 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 { Request, Response } from "express";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { import {
@@ -24,10 +25,11 @@ import {
getUserProjectPermissions getUserProjectPermissions
} from "../../services/ProjectRoleService"; } from "../../services/ProjectRoleService";
import { ForbiddenError } from "@casl/ability"; 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 { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip";
import { EEAuditLogService, EELicenseService } from "../../services"; import { EEAuditLogService, EELicenseService } from "../../services";
import { getJwtServiceTokenSecret } from "../../../config"; import { getAuthSecret } from "../../../config";
import { AuthTokenType } from "../../../variables";
/** /**
* Return project key for service token V3 * 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 * Create service token data V3
* @param req * @param req
@@ -71,8 +166,10 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
scopes, scopes,
trustedIps, trustedIps,
expiresIn, expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled,
encryptedKey, // for ServiceTokenDataV3Key encryptedKey, // for ServiceTokenDataV3Key
nonce // for ServiceTokenDataV3Key nonce, // for ServiceTokenDataV3Key
} }
} = await validateRequest(reqValidator.CreateServiceTokenV3, req); } = await validateRequest(reqValidator.CreateServiceTokenV3, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
@@ -112,17 +209,21 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
user = req.authData.authPayload._id; user = req.authData.authPayload._id;
} }
const isActive = true; const isActive = false;
const serviceTokenData = await new ServiceTokenDataV3({ const serviceTokenData = await new ServiceTokenDataV3({
name, name,
user, user,
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
publicKey, publicKey,
usageCount: 0, refreshTokenUsageCount: 0,
accessTokenUsageCount: 0,
tokenVersion: 1,
trustedIps: reformattedTrustedIps, trustedIps: reformattedTrustedIps,
scopes, scopes,
isActive, isActive,
expiresAt expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}).save(); }).save();
await new ServiceTokenDataV3Key({ await new ServiceTokenDataV3Key({
@@ -133,18 +234,19 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
workspace: new Types.ObjectId(workspaceId) workspace: new Types.ObjectId(workspaceId)
}).save(); }).save();
const token = createToken({ const refreshToken = createToken({
payload: { payload: {
_id: serviceTokenData._id.toString() serviceTokenDataId: serviceTokenData._id.toString(),
authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN,
tokenVersion: serviceTokenData.tokenVersion
}, },
expiresIn, secret: await getAuthSecret()
secret: await getJwtServiceTokenSecret()
}); });
await EEAuditLogService.createAuditLog( await EEAuditLogService.createAuditLog(
req.authData, req.authData,
{ {
type: EventType.CREATE_SERVICE_TOKEN_V3, type: EventType.CREATE_SERVICE_TOKEN_V3, // TODO: update
metadata: { metadata: {
name, name,
isActive, isActive,
@@ -160,7 +262,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
return res.status(200).send({ return res.status(200).send({
serviceTokenData, serviceTokenData,
serviceToken: `stv3.${token}` refreshToken
}); });
} }
@@ -178,7 +280,9 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
isActive, isActive,
scopes, scopes,
trustedIps, trustedIps,
expiresIn expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
} }
} = await validateRequest(reqValidator.UpdateServiceTokenV3, req); } = await validateRequest(reqValidator.UpdateServiceTokenV3, req);
@@ -233,7 +337,9 @@ export const updateServiceTokenData = async (req: Request, res: Response) => {
isActive, isActive,
scopes, scopes,
trustedIps: reformattedTrustedIps, trustedIps: reformattedTrustedIps,
expiresAt expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}, },
{ {
new: true new: true
+6 -1
View File
@@ -7,11 +7,16 @@ import { serviceTokenDataController } from "../../controllers/v3";
router.get( router.get(
"/me/key", "/me/key",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
serviceTokenDataController.getServiceTokenDataKey serviceTokenDataController.getServiceTokenDataKey
); );
router.post(
"/me/token",
serviceTokenDataController.refreshToken
);
router.post( router.post(
"/", "/",
requireAuth({ requireAuth({
+39 -24
View File
@@ -22,8 +22,7 @@ import {
import { import {
getAuthSecret, getAuthSecret,
getJwtAuthLifetime, getJwtAuthLifetime,
getJwtRefreshLifetime, getJwtRefreshLifetime
getJwtServiceTokenSecret
} from "../config"; } from "../config";
import { import {
AuthMode, AuthMode,
@@ -43,7 +42,7 @@ import { getUserAgentType } from "../utils/posthog";
* @param {Object} obj * @param {Object} obj
* @param {Object} obj.headers - HTTP request headers object * @param {Object} obj.headers - HTTP request headers object
*/ */
export const validateAuthMode = ({ export const validateAuthMode = async ({
headers, headers,
acceptedAuthModes, acceptedAuthModes,
}: { }: {
@@ -84,13 +83,19 @@ export const validateAuthMode = ({
authMode = AuthMode.SERVICE_TOKEN; authMode = AuthMode.SERVICE_TOKEN;
authTokenValue = tokenValue; authTokenValue = tokenValue;
break; break;
case "stv3": default: {
authMode = AuthMode.SERVICE_TOKEN_V3; const decodedToken = <jwt.UserIDJwtPayload>(
authTokenValue = parts.slice(1).join("."); jwt.verify(tokenValue, await getAuthSecret())
break; );
default:
authMode = AuthMode.JWT; if (decodedToken.authTokenType === AuthTokenType.SERVICE_ACCESS_TOKEN) {
authMode = AuthMode.SERVICE_ACCESS_TOKEN;
} else {
authMode = AuthMode.JWT;
}
authTokenValue = tokenValue; authTokenValue = tokenValue;
}
} }
} }
@@ -254,23 +259,17 @@ export const getAuthSTDPayload = async ({
req: Request, req: Request,
authTokenValue: string; authTokenValue: string;
}): Promise<ServiceTokenV3AuthData> => { }): Promise<ServiceTokenV3AuthData> => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getJwtServiceTokenSecret()) const decodedToken = <jwt.ServiceRefreshTokenJwtPayload>(
jwt.verify(authTokenValue, await getAuthSecret())
); );
const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate( if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError();
{
_id: new Types.ObjectId(decodedToken._id), const serviceTokenData = await ServiceTokenDataV3.findOne({
isActive: true _id: new Types.ObjectId(decodedToken.serviceTokenDataId),
}, isActive: true
{ });
lastUsed: new Date(),
$inc: { usageCount: 1 }
},
{
new: true
}
);
if (!serviceTokenData) { if (!serviceTokenData) {
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
@@ -288,11 +287,27 @@ export const getAuthSTDPayload = async ({
} }
); );
throw UnauthorizedRequestError({
message: "Failed to authenticate",
});
} else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) {
// TODO: raise alarm
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: "Failed to authenticate", message: "Failed to authenticate",
}); });
} }
await ServiceTokenDataV3.findByIdAndUpdate(
serviceTokenData._id,
{
accessTokenLastUsed: new Date(),
$inc: { accessTokenUsageCount: 1 }
},
{
new: true
}
);
return { return {
actor: { actor: {
type: ActorType.SERVICE_V3, type: ActorType.SERVICE_V3,
+2 -2
View File
@@ -35,7 +35,7 @@ const requireAuth = ({
// validate auth token against accepted auth modes [acceptedAuthModes] // validate auth token against accepted auth modes [acceptedAuthModes]
// and return token type [authTokenType] and value [authTokenValue] // and return token type [authTokenType] and value [authTokenValue]
const { authMode, authTokenValue } = validateAuthMode({ const { authMode, authTokenValue } = await validateAuthMode({
headers: req.headers, headers: req.headers,
acceptedAuthModes, acceptedAuthModes,
}); });
@@ -50,7 +50,7 @@ const requireAuth = ({
}); });
req.serviceTokenData = authData.authPayload; req.serviceTokenData = authData.authPayload;
break; break;
case AuthMode.SERVICE_TOKEN_V3: case AuthMode.SERVICE_ACCESS_TOKEN:
authData = await getAuthSTDV3Payload({ authData = await getAuthSTDV3Payload({
req, req,
authTokenValue authTokenValue
+35 -6
View File
@@ -25,9 +25,14 @@ export interface IServiceTokenDataV3 extends Document {
user: Types.ObjectId; user: Types.ObjectId;
publicKey: string; publicKey: string;
isActive: boolean; isActive: boolean;
lastUsed?: Date; refreshTokenLastUsed?: Date;
usageCount: number; accessTokenLastUsed?: Date;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
tokenVersion: number;
isRefreshTokenRotationEnabled: boolean;
expiresAt?: Date; expiresAt?: Date;
accessTokenTTL: number;
scopes: Array<IServiceTokenV3Scope>; scopes: Array<IServiceTokenV3Scope>;
trustedIps: Array<IServiceTokenV3TrustedIp>; trustedIps: Array<IServiceTokenV3TrustedIp>;
} }
@@ -57,19 +62,43 @@ const serviceTokenDataV3Schema = new Schema(
default: true, default: true,
required: true required: true
}, },
lastUsed: { refreshTokenLastUsed: {
type: Date, type: Date,
required: false required: false
}, },
usageCount: { accessTokenLastUsed: {
type: Date,
required: false
},
refreshTokenUsageCount: {
type: Number, type: Number,
default: 0, default: 0,
required: true required: true
}, },
expiresAt: { 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, type: Date,
required: false, required: false,
expires: 0 // expires: 0
},
accessTokenTTL: { // seconds
type: Number,
default: 7200,
required: true
}, },
scopes: { scopes: {
type: [ type: [
+10 -10
View File
@@ -7,7 +7,7 @@ import { AuthMode } from "../../variables";
router.get( router.get(
"/raw", "/raw",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
secretsController.getSecretsRaw secretsController.getSecretsRaw
); );
@@ -15,7 +15,7 @@ router.get(
router.get( router.get(
"/raw/:secretName", "/raw/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "query" locationWorkspaceId: "query"
@@ -29,7 +29,7 @@ router.get(
router.post( router.post(
"/raw/:secretName", "/raw/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
@@ -43,7 +43,7 @@ router.post(
router.patch( router.patch(
"/raw/:secretName", "/raw/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
@@ -57,7 +57,7 @@ router.patch(
router.delete( router.delete(
"/raw/:secretName", "/raw/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
@@ -71,7 +71,7 @@ router.delete(
router.get( router.get(
"/", "/",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "query" locationWorkspaceId: "query"
@@ -116,7 +116,7 @@ router.delete(
router.post( router.post(
"/:secretName", "/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
@@ -127,7 +127,7 @@ router.post(
router.get( router.get(
"/:secretName", "/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "query" locationWorkspaceId: "query"
@@ -138,7 +138,7 @@ router.get(
router.patch( router.patch(
"/:secretName", "/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
@@ -149,7 +149,7 @@ router.patch(
router.delete( router.delete(
"/:secretName", "/:secretName",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN]
}), }),
requireBlindIndicesEnabled({ requireBlindIndicesEnabled({
locationWorkspaceId: "body" locationWorkspaceId: "body"
+12 -2
View File
@@ -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({ export const CreateServiceTokenV3 = z.object({
body: z.object({ body: z.object({
name: z.string().trim(), name: z.string().trim(),
@@ -80,8 +86,10 @@ export const CreateServiceTokenV3 = z.object({
.array() .array()
.min(1), .min(1),
expiresIn: z.number().optional(), expiresIn: z.number().optional(),
accessTokenTTL: z.number().int().min(1),
encryptedKey: z.string().trim(), 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() .array()
.min(1) .min(1)
.optional(), .optional(),
expiresIn: z.number().optional() expiresIn: z.number().optional(),
accessTokenTTL: z.number().int().min(1).optional(),
isRefreshTokenRotationEnabled: z.boolean().optional()
}), }),
}); });
+9 -5
View File
@@ -1,16 +1,20 @@
// TODO: merge [AuthTokenType] and [AuthMode]
export enum AuthTokenType { export enum AuthTokenType {
ACCESS_TOKEN = "accessToken", ACCESS_TOKEN = "accessToken",
REFRESH_TOKEN = "refreshToken", REFRESH_TOKEN = "refreshToken",
SIGNUP_TOKEN = "signupToken", SIGNUP_TOKEN = "signupToken", // TODO: remove in favor of claim
MFA_TOKEN = "mfaToken", MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim
PROVIDER_TOKEN = "providerToken", PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim
API_KEY = "apiKey" API_KEY = "apiKey",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
SERVICE_REFRESH_TOKEN = "serviceRefreshToken"
} }
export enum AuthMode { export enum AuthMode {
JWT = "jwt", JWT = "jwt",
SERVICE_TOKEN = "serviceToken", SERVICE_TOKEN = "serviceToken",
SERVICE_TOKEN_V3 = "serviceTokenV3", SERVICE_ACCESS_TOKEN = "serviceAccessToken",
API_KEY = "apiKey" API_KEY = "apiKey"
} }
@@ -88,14 +88,18 @@ export const useUpdateServiceTokenV3 = () => {
isActive, isActive,
scopes, scopes,
trustedIps, trustedIps,
expiresIn expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => { }) => {
const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, { const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, {
name, name,
isActive, isActive,
scopes, scopes,
trustedIps, trustedIps,
expiresIn expiresIn,
accessTokenTTL,
isRefreshTokenRotationEnabled
}); });
return serviceTokenData; return serviceTokenData;
+11 -3
View File
@@ -56,11 +56,15 @@ export type ServiceTokenDataV3 = {
name: string; name: string;
workspace: string; workspace: string;
isActive: boolean; isActive: boolean;
lastUsed?: string; refreshTokenLastUsed?: string;
usageCount: number; accessTokenLastUsed?: string;
refreshTokenUsageCount: number;
accessTokenUsageCount: number;
scopes: ServiceTokenV3Scope[]; scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[]; trustedIps: ServiceTokenV3TrustedIp[];
expiresAt?: string; expiresAt?: string;
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -74,12 +78,14 @@ export type CreateServiceTokenDataV3DTO = {
ipAddress: string; ipAddress: string;
}[]; }[];
expiresIn?: number; expiresIn?: number;
accessTokenTTL: number;
encryptedKey: string; encryptedKey: string;
nonce: string; nonce: string;
isRefreshTokenRotationEnabled: boolean;
} }
export type CreateServiceTokenDataV3Res = { export type CreateServiceTokenDataV3Res = {
serviceToken: string; refreshToken: string;
serviceTokenData: ServiceTokenDataV3; serviceTokenData: ServiceTokenDataV3;
} }
@@ -92,6 +98,8 @@ export type UpdateServiceTokenDataV3DTO = {
ipAddress: string; ipAddress: string;
}[]; }[];
expiresIn?: number; expiresIn?: number;
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
} }
export type DeleteServiceTokenDataV3DTO = { export type DeleteServiceTokenDataV3DTO = {
@@ -21,6 +21,7 @@ import {
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
Switch,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
@@ -42,7 +43,7 @@ import {
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const expirations = [ const expirations = [
{ label: "Never", value: undefined }, { label: "Never", value: "" },
{ label: "1 day", value: "86400" }, { label: "1 day", value: "86400" },
{ label: "7 days", value: "604800" }, { label: "7 days", value: "604800" },
{ label: "1 month", value: "2592000" }, { label: "1 month", value: "2592000" },
@@ -60,6 +61,13 @@ const permissionsMap: {
const schema = yup.object({ const schema = yup.object({
name: yup.string().required("ST V3 name is required"), name: yup.string().required("ST V3 name is required"),
expiresIn: yup.string(), expiresIn: yup.string(),
accessTokenTTL: yup
.string()
.test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => {
const num = parseInt(value, 10);
return !Number.isNaN(num) && num > 0 && String(num) === value;
})
.required("Access Token TTL is required"),
scopes: yup scopes: yup
.array( .array(
yup.object({ yup.object({
@@ -79,14 +87,15 @@ const schema = yup.object({
.required() .required()
.label("Scope"), .label("Scope"),
trustedIps: yup trustedIps: yup
.array( .array(
yup.object({ yup.object({
ipAddress: yup.string().max(50).required().label("IP Address") ipAddress: yup.string().max(50).required().label("IP Address")
}) })
) )
.min(1) .min(1)
.required() .required()
.label("Trusted IP") .label("Trusted IP"),
isRefreshTokenRotationEnabled: yup.boolean().default(false)
}).required(); }).required();
export type FormData = yup.InferType<typeof schema>; export type FormData = yup.InferType<typeof schema>;
@@ -118,6 +127,7 @@ export const AddServiceTokenV3Modal = ({
resolver: yupResolver(schema), resolver: yupResolver(schema),
defaultValues: { defaultValues: {
name: "", name: "",
accessTokenTTL: "7200",
scopes: [{ scopes: [{
permission: "read", permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug, environment: currentWorkspace?.environments?.[0]?.slug,
@@ -135,6 +145,8 @@ export const AddServiceTokenV3Modal = ({
name: string; name: string;
scopes: ServiceTokenV3Scope[]; scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[]; trustedIps: ServiceTokenV3TrustedIp[];
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
}; };
if (serviceTokenData) { if (serviceTokenData) {
@@ -163,11 +175,14 @@ export const AddServiceTokenV3Modal = ({
return ({ return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
}); });
}) }),
accessTokenTTL: String(serviceTokenData.accessTokenTTL),
isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled
}); });
} else { } else {
reset({ reset({
name: "", name: "",
accessTokenTTL: "7200",
scopes: [{ scopes: [{
permission: "read", permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug, environment: currentWorkspace?.environments?.[0]?.slug,
@@ -186,8 +201,10 @@ export const AddServiceTokenV3Modal = ({
const onFormSubmit = async ({ const onFormSubmit = async ({
name, name,
expiresIn, expiresIn,
accessTokenTTL,
scopes, scopes,
trustedIps trustedIps,
isRefreshTokenRotationEnabled
}: FormData) => { }: FormData) => {
try { try {
const serviceTokenData = popUp?.serviceTokenV3?.data as { const serviceTokenData = popUp?.serviceTokenV3?.data as {
@@ -213,7 +230,9 @@ export const AddServiceTokenV3Modal = ({
name, name,
scopes: reformattedScopes, scopes: reformattedScopes,
trustedIps, trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn) expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
}); });
} else { } else {
// create // create
@@ -239,21 +258,23 @@ export const AddServiceTokenV3Modal = ({
privateKey: localStorage.getItem("PRIVATE_KEY") as string privateKey: localStorage.getItem("PRIVATE_KEY") as string
}); });
const { serviceToken } = await createMutateAsync({ const { refreshToken } = await createMutateAsync({
name, name,
workspaceId: currentWorkspace._id, workspaceId: currentWorkspace._id,
publicKey, publicKey,
scopes: reformattedScopes, scopes: reformattedScopes,
trustedIps, trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn), expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
encryptedKey: ciphertext, encryptedKey: ciphertext,
nonce nonce,
isRefreshTokenRotationEnabled
}); });
const downloadData = { const downloadData = {
publicKey, publicKey,
privateKey, privateKey,
serviceToken refreshToken
}; };
const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" });
@@ -476,10 +497,10 @@ export const AddServiceTokenV3Modal = ({
<Controller <Controller
control={control} control={control}
name="expiresIn" name="expiresIn"
defaultValue="15552000" defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Expire In`} label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Refresh Token Expires In`}
errorText={error?.message} errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
className="mt-4" className="mt-4"
@@ -499,6 +520,38 @@ export const AddServiceTokenV3Modal = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
defaultValue="7200"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="7200"
/>
</FormControl>
)}
/>
<div className="mt-8 mb-[2.36rem]">
<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>
)}
/>
</div>
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -34,6 +34,8 @@ type Props = {
name?: string; name?: string;
scopes?: ServiceTokenV3Scope[]; scopes?: ServiceTokenV3Scope[];
trustedIps?: ServiceTokenV3TrustedIp[]; trustedIps?: ServiceTokenV3TrustedIp[];
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
} }
) => void; ) => void;
}; };
@@ -81,10 +83,9 @@ export const ServiceTokenV3Table = ({
<Th>Status</Th> <Th>Status</Th>
<Th>Scopes</Th> <Th>Scopes</Th>
<Th>Trusted IPs</Th> <Th>Trusted IPs</Th>
{/* <Th># Times Used</Th> */} <Th>Access Token TTL</Th>
<Th>Last Used</Th>
<Th>Created At</Th> <Th>Created At</Th>
<Th>Expires At</Th> <Th>Valid Until</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -97,12 +98,12 @@ export const ServiceTokenV3Table = ({
_id, _id,
name, name,
isActive, isActive,
lastUsed,
// usageCount,
scopes, scopes,
trustedIps, trustedIps,
createdAt, createdAt,
expiresAt expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => { }) => {
return ( return (
<Tr className="h-10" key={`st-v3-${_id}`}> <Tr className="h-10" key={`st-v3-${_id}`}>
@@ -160,8 +161,7 @@ export const ServiceTokenV3Table = ({
); );
})} })}
</Td> </Td>
{/* <Td>{usageCount}</Td> */} <Td>{accessTokenTTL}</Td>
<Td>{lastUsed ? format(new Date(lastUsed), "yyyy-MM-dd") : "-"}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td> <Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td>
<Td className="flex justify-end"> <Td className="flex justify-end">
@@ -176,7 +176,9 @@ export const ServiceTokenV3Table = ({
serviceTokenDataId: _id, serviceTokenDataId: _id,
name, name,
scopes, scopes,
trustedIps trustedIps,
accessTokenTTL,
isRefreshTokenRotationEnabled
}); });
}} }}
size="lg" size="lg"