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 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;
@@ -28,6 +28,11 @@ declare module "jsonwebtoken" {
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);
@@ -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"
});
+6 -1
View File
@@ -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({
+39 -24
View File
@@ -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.UserIDJwtPayload>(
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<ServiceTokenV3AuthData> => {
const decodedToken = <jwt.UserIDJwtPayload>(
jwt.verify(authTokenValue, await getJwtServiceTokenSecret())
const decodedToken = <jwt.ServiceRefreshTokenJwtPayload>(
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: {
+2 -2
View File
@@ -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
+35 -6
View File
@@ -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
},
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: [
+10 -10
View File
@@ -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"
+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({
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()
}),
});
+9 -5
View File
@@ -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"
}