From eac5a0661c98ea94d8abeb93dadadca92a4eb5da Mon Sep 17 00:00:00 2001 From: yunho-microsoft <75456899+yunho-microsoft@users.noreply.github.com> Date: Wed, 17 May 2023 16:33:40 -0700 Subject: [PATCH] Add token cache support in alfred (#15612) This PR includes changes below: 1. Add token cache in Alfred. It reuse the single token cache as the cache for all tokens. And there is no need to have a separate single token use cache if token cache is enabled 2. Add a new function verifyToken with taken cache and refactor verifyStorageToken to use this new function. It should be used in other places where we need to verify token. --------- Co-authored-by: Yunpeng Hou --- .../src/alfred/routes/api/api.ts | 42 +++++- .../src/alfred/routes/api/deltas.ts | 23 +++- .../src/alfred/routes/api/documents.ts | 25 +++- .../src/alfred/routes/api/index.ts | 2 + .../packages/routerlicious/config/config.json | 3 + .../packages/services-utils/src/auth.ts | 125 +++++++++++++++++- .../services-utils/src/configUtils.ts | 29 ++++ .../packages/services-utils/src/index.ts | 2 + 8 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 server/routerlicious/packages/services-utils/src/configUtils.ts diff --git a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/api.ts b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/api.ts index efa407e5e6f7..3912a1fb882b 100644 --- a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/api.ts +++ b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/api.ts @@ -17,6 +17,8 @@ import { IThrottleMiddlewareOptions, getParam, getCorrelationId, + getBooleanFromConfig, + verifyToken, } from "@fluidframework/server-services-utils"; import { validateRequestParams, handleResponse } from "@fluidframework/server-services"; import { Request, Router } from "express"; @@ -40,6 +42,7 @@ export function create( tenantManager: core.ITenantManager, storage: core.IDocumentStorage, tenantThrottlers: Map, + jwtTokenCache?: core.ICache, tokenManager?: core.ITokenRevocationManager, ): Router { const router: Router = Router(); @@ -50,6 +53,12 @@ export function create( }; const generalTenantThrottler = tenantThrottlers.get(Constants.generalRestCallThrottleIdPrefix); + // Jwt token cache + const enableJwtTokenCache: boolean = getBooleanFromConfig( + "alfred:jwtTokenCache:enable", + config, + ); + function handlePatchRootSuccess(request: Request, opBuilder: (request: Request) => any[]) { const tenantId = getParam(request.params, "tenantId"); const documentId = getParam(request.params, "id"); @@ -83,6 +92,8 @@ export function create( storage, maxTokenLifetimeSec, isTokenExpiryEnabled, + enableJwtTokenCache, + jwtTokenCache, tokenManager, ); handleResponse( @@ -200,32 +211,44 @@ const verifyRequest = async ( storage: core.IDocumentStorage, maxTokenLifetimeSec: number, isTokenExpiryEnabled: boolean, + tokenCacheEnabled: boolean, + tokenCache?: core.ICache, tokenManager?: core.ITokenRevocationManager, ) => Promise.all([ - verifyToken( + verifyTokenWrapper( request, tenantManager, maxTokenLifetimeSec, isTokenExpiryEnabled, + tokenCacheEnabled, + tokenCache, tokenManager, ), checkDocumentExistence(request, storage), ]); -async function verifyToken( +async function verifyTokenWrapper( request: Request, tenantManager: core.ITenantManager, maxTokenLifetimeSec: number, isTokenExpiryEnabled: boolean, + tokenCacheEnabled: boolean, + tokenCache?: core.ICache, tokenManager?: core.ITokenRevocationManager, ): Promise { const token = request.headers["access-token"] as string; if (!token) { - return Promise.reject(new Error("Missing access token")); + return Promise.reject(new Error("Missing access token in request header.")); } const tenantId = getParam(request.params, "tenantId"); + if (!tenantId) { + return Promise.reject(new Error("Missing tenantId in request.")); + } const documentId = getParam(request.params, "id"); + if (!documentId) { + return Promise.reject(new Error("Missing documentId in request.")); + } const claims = validateTokenClaims(token, documentId, tenantId); if (isTokenExpiryEnabled) { validateTokenClaimsExpiration(claims, maxTokenLifetimeSec); @@ -238,6 +261,19 @@ async function verifyToken( } } + if (tokenCacheEnabled && tokenCache) { + const options = { + requireDocumentId: true, + requireTokenExpiryCheck: isTokenExpiryEnabled, + maxTokenLifetimeSec, + ensureSingleUseToken: false, + singleUseTokenCache: undefined, + enableTokenCache: true, + tokenCache, + }; + return verifyToken(tenantId, documentId, token, tenantManager, options); + } + return tenantManager.verifyToken(claims.tenantId, token); } diff --git a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/deltas.ts b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/deltas.ts index 4d939dd69bd2..6428ce68c952 100644 --- a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/deltas.ts +++ b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/deltas.ts @@ -4,6 +4,7 @@ */ import { + ICache, IDeltaService, ITenantManager, IThrottler, @@ -14,6 +15,7 @@ import { throttle, IThrottleMiddlewareOptions, getParam, + getBooleanFromConfig, } from "@fluidframework/server-services-utils"; import { validateRequestParams, handleResponse } from "@fluidframework/server-services"; import { Router } from "express"; @@ -29,6 +31,7 @@ export function create( appTenants: IAlfredTenant[], tenantThrottlers: Map, clusterThrottlers: Map, + jwtTokenCache?: ICache, tokenManager?: ITokenRevocationManager, ): Router { const deltasCollectionName = config.get("mongo:collectionNames:deltas"); @@ -51,6 +54,20 @@ export function create( throttleIdSuffix: Constants.alfredRestThrottleIdSuffix, }; + // Jwt token cache + const enableJwtTokenCache: boolean = getBooleanFromConfig( + "alfred:jwtTokenCache:enable", + config, + ); + + const defaultTokenValidationOptions = { + requireDocumentId: true, + ensureSingleUseToken: false, + singleUseTokenCache: undefined, + enableTokenCache: enableJwtTokenCache, + tokenCache: jwtTokenCache, + }; + function stringToSequenceNumber(value: any): number { if (typeof value !== "string") { return undefined; @@ -66,7 +83,7 @@ export function create( router.get( ["/v1/:tenantId/:id", "/:tenantId/:id/v1"], validateRequestParams("tenantId", "id"), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), throttle(generalTenantThrottler, winston, tenantThrottleOptions), (request, response, next) => { const from = stringToSequenceNumber(request.query.from); @@ -92,7 +109,7 @@ export function create( router.get( "/raw/:tenantId/:id", validateRequestParams("tenantId", "id"), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), throttle(generalTenantThrottler, winston, tenantThrottleOptions), (request, response, next) => { const tenantId = getParam(request.params, "tenantId") || appTenants[0].id; @@ -124,7 +141,7 @@ export function create( winston, getDeltasTenantThrottleOptions, ), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), (request, response, next) => { const from = stringToSequenceNumber(request.query.from); const to = stringToSequenceNumber(request.query.to); diff --git a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/documents.ts b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/documents.ts index 7904e4399ea4..8a7b8ddc390a 100644 --- a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/documents.ts +++ b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/documents.ts @@ -19,6 +19,7 @@ import { IThrottleMiddlewareOptions, getParam, validateTokenScopeClaims, + getBooleanFromConfig, } from "@fluidframework/server-services-utils"; import { validateRequestParams, handleResponse } from "@fluidframework/server-services"; import { Router } from "express"; @@ -86,10 +87,24 @@ export function create( throttleIdSuffix: Constants.alfredRestThrottleIdSuffix, }; + // Jwt token cache + const enableJwtTokenCache: boolean = getBooleanFromConfig( + "alfred:jwtTokenCache:enable", + config, + ); + + const defaultTokenValidationOptions = { + requireDocumentId: true, + ensureSingleUseToken: false, + singleUseTokenCache: undefined, + enableTokenCache: enableJwtTokenCache, + tokenCache: singleUseTokenCache, + }; + router.get( "/:tenantId/:id", validateRequestParams("tenantId", "id"), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), throttle(generalTenantThrottler, winston, tenantThrottleOptions), (request, response, next) => { const documentP = storage.getDocument( @@ -120,6 +135,8 @@ export function create( requireDocumentId: false, ensureSingleUseToken: true, singleUseTokenCache, + enableTokenCache: enableJwtTokenCache, + tokenCache: singleUseTokenCache, }), throttle( clusterThrottlers.get(Constants.createDocThrottleIdPrefix), @@ -210,7 +227,7 @@ export function create( */ router.get( "/:tenantId/session/:id", - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), throttle( clusterThrottlers.get(Constants.getSessionThrottleIdPrefix), winston, @@ -244,7 +261,7 @@ export function create( "/:tenantId/document/:id", validateRequestParams("tenantId", "id"), validateTokenScopeClaims(DocDeleteScopeType), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), async (request, response, next) => { const documentId = getParam(request.params, "id"); const tenantId = getParam(request.params, "tenantId"); @@ -263,7 +280,7 @@ export function create( "/:tenantId/document/:id/revokeToken", validateRequestParams("tenantId", "id"), validateTokenScopeClaims(TokenRevokeScopeType), - verifyStorageToken(tenantManager, config, tokenManager), + verifyStorageToken(tenantManager, config, tokenManager, defaultTokenValidationOptions), throttle(generalTenantThrottler, winston, tenantThrottleOptions), async (request, response, next) => { const documentId = getParam(request.params, "id"); diff --git a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/index.ts b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/index.ts index a094e03418fa..2d9a1cc8b4d5 100644 --- a/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/index.ts +++ b/server/routerlicious/packages/routerlicious-base/src/alfred/routes/api/index.ts @@ -44,6 +44,7 @@ export function create( appTenants, tenantThrottlers, clusterThrottlers, + singleUseTokenCache, tokenManager, ); const documentsRoute = documents.create( @@ -64,6 +65,7 @@ export function create( tenantManager, storage, tenantThrottlers, + singleUseTokenCache, tokenManager, ); diff --git a/server/routerlicious/packages/routerlicious/config/config.json b/server/routerlicious/packages/routerlicious/config/config.json index 7df83569b380..d3382a4c6972 100644 --- a/server/routerlicious/packages/routerlicious/config/config.json +++ b/server/routerlicious/packages/routerlicious/config/config.json @@ -104,6 +104,9 @@ "enforceServerGeneratedDocumentId": false, "socketIo": { "perMessageDeflate": true + }, + "jwtTokenCache": { + "enable": true } }, "client": { diff --git a/server/routerlicious/packages/services-utils/src/auth.ts b/server/routerlicious/packages/services-utils/src/auth.ts index e73f16b63c89..68bafb15ab51 100644 --- a/server/routerlicious/packages/services-utils/src/auth.ts +++ b/server/routerlicious/packages/services-utils/src/auth.ts @@ -26,6 +26,7 @@ import type { import type { RequestHandler, Request, Response } from "express"; import type { Provider } from "nconf"; import { getLumberBaseProperties, Lumberjack } from "@fluidframework/server-services-telemetry"; +import { getBooleanFromConfig, getNumberFromConfig } from "./configUtils"; /** * Validates a JWT token to authorize routerlicious. @@ -122,8 +123,12 @@ export function generateUser(): IUser { interface IVerifyTokenOptions { requireDocumentId: boolean; + requireTokenExpiryCheck?: boolean; + maxTokenLifetimeSec?: number; ensureSingleUseToken: boolean; singleUseTokenCache: ICache | undefined; + enableTokenCache: boolean; + tokenCache: ICache | undefined; } export function respondWithNetworkError(response: Response, error: NetworkError): Response { @@ -143,6 +148,81 @@ function getTokenFromRequest(request: Request): string { return tokenMatch[1]; } +const defaultMaxTokenLifetimeSec = 60 * 60; // 1 hour + +export async function verifyToken( + tenantId: string, + documentId: string, + token: string, + tenantManager: ITenantManager, + options: IVerifyTokenOptions, +): Promise { + if (options.requireDocumentId && !documentId) { + throw new NetworkError(403, "Missing documentId."); + } + + let tokenLifetimeMs: number | undefined; + const logProperties = getLumberBaseProperties(documentId, tenantId); + try { + const claims = validateTokenClaims(token, documentId, tenantId, options.requireDocumentId); + if (options.requireTokenExpiryCheck) { + let maxTokenLifetimeSec = options.maxTokenLifetimeSec; + if (!maxTokenLifetimeSec) { + Lumberjack.error( + `Missing/Invalid maxTokenLifetimeSec=${maxTokenLifetimeSec} in options. Set to default=${defaultMaxTokenLifetimeSec}`, + logProperties, + ); + maxTokenLifetimeSec = defaultMaxTokenLifetimeSec; + } + tokenLifetimeMs = validateTokenClaimsExpiration(claims, maxTokenLifetimeSec); + } + + // Check token cache first + if ((options.enableTokenCache || options.ensureSingleUseToken) && options.tokenCache) { + const cachedToken = await options.tokenCache.get(token).catch((error) => { + Lumberjack.error("Unable to retrieve cached JWT", logProperties, error); + return false; + }); + + if (cachedToken) { + Lumberjack.info("Token cache hit", logProperties); + if (options.ensureSingleUseToken) { + throw new NetworkError(403, "Access token has already been used."); + } + return; + } + } + + await tenantManager.verifyToken(claims.tenantId, token); + + // Update token cache + if ((options.enableTokenCache || options.ensureSingleUseToken) && options.tokenCache) { + Lumberjack.info("Token cache miss", logProperties); + const tokenCacheKey = token; + options.tokenCache + .set( + tokenCacheKey, + "used", + tokenLifetimeMs !== undefined ? Math.floor(tokenLifetimeMs / 1000) : undefined, + ) + .catch((error) => { + Lumberjack.error("Unable to cache JWT", logProperties, error); + }); + } + } catch (error) { + if (isNetworkError(error)) { + throw error; + } + // We don't understand the error, so it is likely an internal service error. + Lumberjack.error( + "Unrecognized error when validating/verifying request token", + logProperties, + error, + ); + throw new NetworkError(500, "Internal server error."); + } +} + /** * Verifies the storage token claims and calls riddler to validate the token. */ @@ -154,11 +234,20 @@ export function verifyStorageToken( requireDocumentId: true, ensureSingleUseToken: false, singleUseTokenCache: undefined, + enableTokenCache: false, + tokenCache: undefined, }, ): RequestHandler { + const maxTokenLifetimeSec = getNumberFromConfig("auth:maxTokenLifetimeSec", config); + const isTokenExpiryEnabled = getBooleanFromConfig("auth:enableTokenExpiration", config); + // Prevent service from starting with invalid configs + if (isTokenExpiryEnabled && isNaN(maxTokenLifetimeSec)) { + throw new Error( + "Invalid configuration: no maxTokenLifetimeSec when token expiry is enabled", + ); + } + return async (request, res, next) => { - const maxTokenLifetimeSec = config.get("auth:maxTokenLifetimeSec") as number; - const isTokenExpiryEnabled = config.get("auth:enableTokenExpiration") as boolean; const tenantId = getParam(request.params, "tenantId"); if (!tenantId) { return respondWithNetworkError( @@ -173,6 +262,38 @@ export function verifyStorageToken( new NetworkError(403, "Missing documentId in request"), ); } + + // TODO: remove this check and code after this block after validation + if (options.enableTokenCache) { + const moreOptions: IVerifyTokenOptions = options; + moreOptions.maxTokenLifetimeSec = maxTokenLifetimeSec; + moreOptions.requireTokenExpiryCheck = isTokenExpiryEnabled; + try { + await verifyToken( + tenantId, + documentId, + getTokenFromRequest(request), + tenantManager, + moreOptions, + ); + return next(); + } catch (error) { + if (isNetworkError(error)) { + return respondWithNetworkError(res, error); + } + // We don't understand the error, so it is likely an internal service error. + Lumberjack.error( + "Unrecognized error when validating/verifying request token", + getLumberBaseProperties(documentId, tenantId), + error, + ); + return respondWithNetworkError( + res, + new NetworkError(500, "Internal server error."), + ); + } + } + let claims: ITokenClaims | undefined; let tokenLifetimeMs: number | undefined; let token: string = ""; diff --git a/server/routerlicious/packages/services-utils/src/configUtils.ts b/server/routerlicious/packages/services-utils/src/configUtils.ts new file mode 100644 index 000000000000..af8205858cbb --- /dev/null +++ b/server/routerlicious/packages/services-utils/src/configUtils.ts @@ -0,0 +1,29 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import nconf from "nconf"; + +export function getBooleanFromConfig(name: string, config: nconf.Provider): boolean { + const rawValue = config.get(name); + + if (typeof rawValue === "boolean") { + return rawValue; + } else if (typeof rawValue === "string") { + return rawValue.toLowerCase() === "true"; + } else { + return false; + } +} + +export function getNumberFromConfig(name: string, config: nconf.Provider): number { + const rawValue = config.get(name); + if (typeof rawValue === "number") { + return rawValue; + } else if (typeof rawValue === "string") { + return Number(rawValue); + } else { + return NaN; + } +} diff --git a/server/routerlicious/packages/services-utils/src/index.ts b/server/routerlicious/packages/services-utils/src/index.ts index bc363544041f..b636edef1d70 100644 --- a/server/routerlicious/packages/services-utils/src/index.ts +++ b/server/routerlicious/packages/services-utils/src/index.ts @@ -17,6 +17,7 @@ export { validateTokenClaims, verifyStorageToken, validateTokenScopeClaims, + verifyToken, } from "./auth"; export { parseBoolean } from "./conversion"; export { deleteSummarizedOps } from "./deleteSummarizedOps"; @@ -40,3 +41,4 @@ export { IThrottleConfig, ISimpleThrottleConfig, getThrottleConfig } from "./thr export { IThrottleMiddlewareOptions, throttle } from "./throttlerMiddleware"; export { WinstonLumberjackEngine } from "./winstonLumberjackEngine"; export { WebSocketTracker, DummyTokenRevocationManager } from "./tokenRevocationManager"; +export { getBooleanFromConfig, getNumberFromConfig } from "./configUtils";