From 65afaa8177d3a55c799640fd72f5cb931dd29e35 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 26 Oct 2023 09:57:59 +0100 Subject: [PATCH] Update ST V3 impl to (rotating) refresh token impl --- backend/src/config/index.ts | 1 - backend/src/controllers/v1/authController.ts | 5 + .../v3/serviceTokenDataController.ts | 136 ++++++++++++++++-- backend/src/ee/routes/v3/serviceTokenData.ts | 7 +- backend/src/helpers/auth.ts | 63 ++++---- backend/src/middleware/requireAuth.ts | 4 +- backend/src/models/serviceTokenDataV3.ts | 41 +++++- backend/src/routes/v3/secrets.ts | 20 +-- backend/src/validation/serviceTokenDataV3.ts | 14 +- backend/src/variables/authentication.ts | 14 +- .../src/hooks/api/serviceTokens/queries.tsx | 8 +- frontend/src/hooks/api/serviceTokens/types.ts | 14 +- .../AddServiceTokenV3Modal.tsx | 87 ++++++++--- .../ServiceTokenV3Table.tsx | 20 +-- 14 files changed, 337 insertions(+), 97 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 402fd5874..c5cb52663 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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; diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index e426eef5a..9e16a4021 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -28,6 +28,11 @@ declare module "jsonwebtoken" { userId: string; refreshVersion?: number; } + export interface ServiceRefreshTokenJwtPayload extends jwt.JwtPayload { + serviceTokenDataId: string; + authTokenType: string; + tokenVersion: number; + } } /** diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts index d233cb0d9..d35988140 100644 --- a/backend/src/ee/controllers/v3/serviceTokenDataController.ts +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -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.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); @@ -112,17 +209,21 @@ export const createServiceTokenData = async (req: Request, res: Response) => { user = req.authData.authPayload._id; } - const isActive = true; + const isActive = false; const serviceTokenData = await new ServiceTokenDataV3({ name, 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" }); diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts index 2f421c7ec..ba1d04854 100644 --- a/backend/src/ee/routes/v3/serviceTokenData.ts +++ b/backend/src/ee/routes/v3/serviceTokenData.ts @@ -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({ diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a8b13fd82..6cee9b6e0 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -22,8 +22,7 @@ import { import { getAuthSecret, getJwtAuthLifetime, - getJwtRefreshLifetime, - getJwtServiceTokenSecret + getJwtRefreshLifetime } from "../config"; import { AuthMode, @@ -43,7 +42,7 @@ import { getUserAgentType } from "../utils/posthog"; * @param {Object} obj * @param {Object} obj.headers - HTTP request headers object */ -export const validateAuthMode = ({ +export const validateAuthMode = async ({ headers, acceptedAuthModes, }: { @@ -84,13 +83,19 @@ export const validateAuthMode = ({ authMode = AuthMode.SERVICE_TOKEN; authTokenValue = tokenValue; break; - case "stv3": - authMode = AuthMode.SERVICE_TOKEN_V3; - authTokenValue = parts.slice(1).join("."); - break; - default: - authMode = AuthMode.JWT; + default: { + const decodedToken = ( + jwt.verify(tokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType === AuthTokenType.SERVICE_ACCESS_TOKEN) { + authMode = AuthMode.SERVICE_ACCESS_TOKEN; + } else { + authMode = AuthMode.JWT; + } + authTokenValue = tokenValue; + } } } @@ -254,23 +259,17 @@ export const getAuthSTDPayload = async ({ req: Request, authTokenValue: string; }): Promise => { - const decodedToken = ( - jwt.verify(authTokenValue, await getJwtServiceTokenSecret()) + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) ); + + if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError(); - const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate( - { - _id: new Types.ObjectId(decodedToken._id), - isActive: true - }, - { - lastUsed: new Date(), - $inc: { usageCount: 1 } - }, - { - new: true - } - ); + const serviceTokenData = await ServiceTokenDataV3.findOne({ + _id: new Types.ObjectId(decodedToken.serviceTokenDataId), + isActive: true + }); if (!serviceTokenData) { throw UnauthorizedRequestError({ @@ -288,10 +287,26 @@ export const getAuthSTDPayload = async ({ } ); + 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 { actor: { diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index aa74b0dc7..5f3246969 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -35,7 +35,7 @@ const requireAuth = ({ // validate auth token against accepted auth modes [acceptedAuthModes] // and return token type [authTokenType] and value [authTokenValue] - const { authMode, authTokenValue } = validateAuthMode({ + const { authMode, authTokenValue } = await validateAuthMode({ headers: req.headers, acceptedAuthModes, }); @@ -50,7 +50,7 @@ const requireAuth = ({ }); req.serviceTokenData = authData.authPayload; break; - case AuthMode.SERVICE_TOKEN_V3: + case AuthMode.SERVICE_ACCESS_TOKEN: authData = await getAuthSTDV3Payload({ req, authTokenValue diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts index c9895402f..198b60507 100644 --- a/backend/src/models/serviceTokenDataV3.ts +++ b/backend/src/models/serviceTokenDataV3.ts @@ -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; trustedIps: Array; } @@ -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 }, - 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, required: false, - expires: 0 + // expires: 0 + }, + accessTokenTTL: { // seconds + type: Number, + default: 7200, + required: true }, scopes: { type: [ diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 960094f87..7aca74e73 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -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.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.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.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.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.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.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -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.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.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.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.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" diff --git a/backend/src/validation/serviceTokenDataV3.ts b/backend/src/validation/serviceTokenDataV3.ts index 976f1a1e5..4be60c645 100644 --- a/backend/src/validation/serviceTokenDataV3.ts +++ b/backend/src/validation/serviceTokenDataV3.ts @@ -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() }), }); diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index 30ba4bf14..2969c88e1 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -1,16 +1,20 @@ +// 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", + SERVICE_ACCESS_TOKEN = "serviceAccessToken", API_KEY = "apiKey" } diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx index 900bb1d34..44786ba0d 100644 --- a/frontend/src/hooks/api/serviceTokens/queries.tsx +++ b/frontend/src/hooks/api/serviceTokens/queries.tsx @@ -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; diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts index d47c3bc4c..18e59c68e 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -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 = { diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx index cc3c81bab..92d701661 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -21,6 +21,7 @@ import { ModalContent, Select, SelectItem, + Switch, UpgradePlanModal } from "@app/components/v2"; import { @@ -42,7 +43,7 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; 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 +61,13 @@ 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) => { + 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 +87,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; @@ -118,6 +127,7 @@ export const AddServiceTokenV3Modal = ({ resolver: yupResolver(schema), defaultValues: { name: "", + accessTokenTTL: "7200", scopes: [{ permission: "read", environment: currentWorkspace?.environments?.[0]?.slug, @@ -135,6 +145,8 @@ export const AddServiceTokenV3Modal = ({ name: string; scopes: ServiceTokenV3Scope[]; trustedIps: ServiceTokenV3TrustedIp[]; + accessTokenTTL: number; + isRefreshTokenRotationEnabled: boolean; }; if (serviceTokenData) { @@ -163,11 +175,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 +201,10 @@ export const AddServiceTokenV3Modal = ({ const onFormSubmit = async ({ name, expiresIn, + accessTokenTTL, scopes, - trustedIps + trustedIps, + isRefreshTokenRotationEnabled }: FormData) => { try { const serviceTokenData = popUp?.serviceTokenV3?.data as { @@ -213,7 +230,9 @@ export const AddServiceTokenV3Modal = ({ name, scopes: reformattedScopes, trustedIps, - expiresIn: expiresIn === "" ? undefined : Number(expiresIn) + expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + accessTokenTTL: Number(accessTokenTTL), + isRefreshTokenRotationEnabled }); } else { // create @@ -239,21 +258,23 @@ 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 + refreshToken }; const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" }); @@ -476,10 +497,10 @@ export const AddServiceTokenV3Modal = ({ ( )} /> + ( + + + + )} + /> +
+ ( + onChange(isChecked)} + isChecked={value} + > + Refresh Token Rotation + + )} + /> +