Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vermadhr/keyless access work #23554

Merged
merged 70 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
bd5c693
Added support for hidden private keys in Riddler
dhr-verma Dec 17, 2024
76325b6
Added key ordering logic
dhr-verma Dec 17, 2024
518b48d
Added unit tests
dhr-verma Dec 17, 2024
abca9ee
Combined the getKey methods
dhr-verma Dec 18, 2024
b97ae1b
Fixed bug in sinon
dhr-verma Dec 18, 2024
39810d2
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
dhr-verma Dec 18, 2024
a4b24eb
Fixed unit test bug
dhr-verma Dec 18, 2024
ab12637
Added more unit tests
dhr-verma Dec 18, 2024
604d986
Added more unit tests for TenantManager
dhr-verma Dec 18, 2024
cf3bcd9
FIxed format
dhr-verma Dec 18, 2024
478b5fb
Fixed formatting
dhr-verma Dec 18, 2024
52cbe69
Added new prop to ITenantConfig
dhr-verma Dec 18, 2024
367c841
Lint fixes
dhr-verma Dec 18, 2024
053741c
Fixed bugs
dhr-verma Dec 19, 2024
d6b4c3c
Changed property name from isKeylessAccessEnabled to enableKeylessAccess
dhr-verma Dec 19, 2024
2332f10
Added documentation
dhr-verma Dec 19, 2024
3ae1ebd
Improved readability
dhr-verma Dec 19, 2024
e615875
Fixed format
dhr-verma Dec 19, 2024
70d1fec
Addressed usePrivateKey comment
dhr-verma Dec 19, 2024
cf51d29
Addressed comments about the keyless token claim
dhr-verma Dec 19, 2024
87e451b
Fixed lint errors
dhr-verma Dec 19, 2024
4d63e65
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
dhr-verma Dec 20, 2024
4108825
Addressed comments about combining the createTenantKeys methods
dhr-verma Dec 20, 2024
e7498a3
Modified keyless access policy API
dhr-verma Dec 20, 2024
3353fab
Fixed format
dhr-verma Dec 20, 2024
4097f71
Added a new tenant config prop enableKeyAccess and made enableKeyless…
dhr-verma Dec 20, 2024
9f58492
Changed logic of checking if key based access is enabled
dhr-verma Dec 20, 2024
89e839f
Changed variable name for readability
dhr-verma Dec 20, 2024
8b5a2b6
Addressed comments
dhr-verma Dec 26, 2024
5fcbd71
Added changesets
dhr-verma Dec 26, 2024
f736d30
Update server/routerlicious/.changeset/six-candles-sneeze.md
dhr-verma Dec 26, 2024
c329bac
Update server/routerlicious/.changeset/six-candles-sneeze.md
dhr-verma Dec 26, 2024
ded23b4
Update server/routerlicious/.changeset/weak-radios-camp.md
dhr-verma Dec 26, 2024
cd8fe82
Addressed comments
dhr-verma Dec 26, 2024
e1f7b20
Merge branch 'vermadhr/keylessAccessWork' of https://github.com/dhr-v…
dhr-verma Dec 26, 2024
015785b
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
dhr-verma Dec 26, 2024
c7efe65
Added support for making API keys optional
dhr-verma Dec 26, 2024
5218c20
Added more unit tests
dhr-verma Dec 27, 2024
19c0307
Added unit tests
dhr-verma Dec 27, 2024
2cc30b7
Added more unit tests
dhr-verma Dec 27, 2024
513c5f9
Removed getKey API call from internal services
dhr-verma Dec 27, 2024
a224f3a
Updated docs
dhr-verma Dec 27, 2024
66efcee
Added debug logs
dhr-verma Dec 27, 2024
3320a28
Added more debug logs
dhr-verma Dec 28, 2024
4f91beb
Fixed encryption bug
dhr-verma Dec 28, 2024
fe308d9
Removed debug logs
dhr-verma Dec 30, 2024
8f195d7
Update server/routerlicious/packages/services/src/tenant.ts
dhr-verma Dec 30, 2024
f94d7c2
Addressed comments
dhr-verma Dec 30, 2024
a79d8d6
Merge branch 'vermadhr/keylessAccessWork' of https://github.com/dhr-v…
dhr-verma Dec 30, 2024
2a0a016
Added changesets
dhr-verma Dec 30, 2024
36ae5d4
Update server/routerlicious/packages/routerlicious-base/src/riddler/a…
dhr-verma Dec 30, 2024
b73ae27
Changed API design for the case where shared keys are disabled
dhr-verma Dec 30, 2024
fb06ce9
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
dhr-verma Dec 30, 2024
b688b0a
Bumped r11 version consumed by Historian and Gitrest
dhr-verma Dec 30, 2024
a2b0acf
Merge branch 'main' of https://github.com/microsoft/FluidFramework in…
dhr-verma Jan 10, 2025
529fde9
Added telemetry
dhr-verma Jan 11, 2025
79916a9
Added debug logs
dhr-verma Jan 11, 2025
29435c0
Changed token lifetime to 5 mins for testing
dhr-verma Jan 13, 2025
8e699ce
Added fix for token refresh
dhr-verma Jan 13, 2025
8a5063c
Added unit tests and fixed bug
dhr-verma Jan 13, 2025
ba45c60
Optimized by removing API call
dhr-verma Jan 14, 2025
e071d0a
FIxed formatting
dhr-verma Jan 14, 2025
bc8021b
Added buffer of expiry time
dhr-verma Jan 14, 2025
0c308cd
Updated API build
dhr-verma Jan 14, 2025
6bc6113
Added token refresh logic to Historian.ts
dhr-verma Jan 14, 2025
ab0956b
Removed user from the logging props
dhr-verma Jan 14, 2025
ffdcbf7
Fixed bug
dhr-verma Jan 14, 2025
3d70480
Removed debug logic
dhr-verma Jan 14, 2025
2e91d7f
Addressed comments
dhr-verma Jan 15, 2025
cbbc85c
Addressed comments
dhr-verma Jan 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ export class TenantManager {
const lumberProperties = {
[BaseTelemetryProperties.tenantId]: tenantId,
includeDisabledTenant,
documentId,
scopes,
lifetime,
ver,
jti,
};
const tenantDocument = await this.getTenantDocument(tenantId, includeDisabledTenant);
if (tenantDocument === undefined) {
Expand Down Expand Up @@ -200,6 +205,10 @@ export class TenantManager {
).key1
: keys.key1;

Lumberjack.info("Signing token with key1", {
...lumberProperties,
isTenantPrivateKeyAccessEnabled,
});
const token = generateToken(
tenantId,
documentId,
Expand All @@ -211,6 +220,10 @@ export class TenantManager {
jti,
isTenantPrivateKeyAccessEnabled,
);
Lumberjack.info("Token signed with key1", {
...lumberProperties,
isTenantPrivateKeyAccessEnabled,
});

return {
fluidAccessToken: token,
Expand All @@ -236,11 +249,12 @@ export class TenantManager {
const lumberProperties = {
[BaseTelemetryProperties.tenantId]: tenantId,
includeDisabledTenant,
isKeylessAccessValidation,
};

// Try validating with Key 1
try {
await this.validateTokenWithKey(tenantKeys.key1, KeyName.key1, token);
await this.validateTokenWithKey(tenantKeys.key1, KeyName.key1, token, lumberProperties);
return;
} catch (error) {
if (isNetworkError(error)) {
Expand Down Expand Up @@ -272,7 +286,7 @@ export class TenantManager {
}
// If Key 1 validation fails, try with Key 2
try {
await this.validateTokenWithKey(tenantKeys.key2, KeyName.key2, token);
await this.validateTokenWithKey(tenantKeys.key2, KeyName.key2, token, lumberProperties);
} catch (error) {
if (isNetworkError(error)) {
if (error.code === 403) {
Expand Down Expand Up @@ -313,6 +327,7 @@ export class TenantManager {
key: string,
keyName: string,
token: string,
lumberjackProperties: any = {},
): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
jwt.verify(token, key, (error) => {
Expand All @@ -324,8 +339,16 @@ export class TenantManager {
// When `exp` claim exists in token claims, jsonwebtoken verifies token expiration.

if (error instanceof jwt.TokenExpiredError) {
Lumberjack.error(
`Token expired validated with ${keyName}.`,
lumberjackProperties,
);
reject(new NetworkError(401, `Token expired validated with ${keyName}.`));
} else {
Lumberjack.error(
`Invalid token validated with ${keyName}.`,
lumberjackProperties,
);
reject(new NetworkError(403, `Invalid token validated with ${keyName}.`));
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { SummaryObject } from '@fluidframework/protocol-definitions';

// @internal (undocumented)
export class BasicRestWrapper extends RestWrapper {
constructor(baseurl?: string, defaultQueryString?: Record<string, string | number | boolean>, maxBodyLength?: number, maxContentLength?: number, defaultHeaders?: RawAxiosRequestHeaders, axios?: AxiosInstance, refreshDefaultQueryString?: (() => Record<string, string | number | boolean>) | undefined, refreshDefaultHeaders?: (() => RawAxiosRequestHeaders) | undefined, getCorrelationId?: (() => string | undefined) | undefined, getTelemetryContextProperties?: (() => Record<string, string | number | boolean> | undefined) | undefined);
constructor(baseurl?: string, defaultQueryString?: Record<string, string | number | boolean>, maxBodyLength?: number, maxContentLength?: number, defaultHeaders?: RawAxiosRequestHeaders, axios?: AxiosInstance, refreshDefaultQueryString?: (() => Record<string, string | number | boolean>) | undefined, refreshDefaultHeaders?: (() => RawAxiosRequestHeaders) | undefined, getCorrelationId?: (() => string | undefined) | undefined, getTelemetryContextProperties?: (() => Record<string, string | number | boolean> | undefined) | undefined, refreshTokenIfNeeded?: ((authorizationHeader: RawAxiosRequestHeaders) => Promise<RawAxiosRequestHeaders | undefined>) | undefined);
// (undocumented)
protected request<T>(requestConfig: AxiosRequestConfig, statusCode: number, canRetry?: boolean): Promise<T>;
}
Expand Down Expand Up @@ -595,6 +595,9 @@ export class NetworkError extends Error {
};
}

// @internal (undocumented)
export function parseToken(tenantId: string, authorization: string | undefined): string | undefined;

// @internal (undocumented)
export function promiseTimeout(mSec: number, promise: Promise<any>): Promise<any>;

Expand Down
30 changes: 30 additions & 0 deletions server/routerlicious/packages/services-client/src/historian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as git from "@fluidframework/gitresources";
import { RestWrapper, BasicRestWrapper } from "./restWrapper";
import { IHistorian } from "./storage";
import { IWholeFlatSummary, IWholeSummaryPayload, IWriteSummaryResponse } from "./storageContracts";
import { NetworkError } from "./error";
import { debug } from "./debug";

function endsWith(value: string, endings: string[]): boolean {
for (const ending of endings) {
Expand All @@ -33,6 +35,34 @@ export interface ICredentials {
export const getAuthorizationTokenFromCredentials = (credentials: ICredentials): string =>
`Basic ${fromUtf8ToBase64(`${credentials.user}:${credentials.password}`)}`;

/**
* @internal
*/
export function parseToken(
tenantId: string,
authorization: string | undefined,
): string | undefined {
let token: string | undefined;
if (authorization) {
const base64TokenMatch = authorization.match(/Basic (.+)/);
if (!base64TokenMatch) {
debug("Invalid base64 token", { tenantId });
throw new NetworkError(403, "Malformed authorization token");
}
const encoded = Buffer.from(base64TokenMatch[1], "base64").toString();

const tokenMatch = encoded.match(/(.+):(.+)/);
if (!tokenMatch || tenantId !== tokenMatch[1]) {
debug("Tenant mismatch or invalid token format", { tenantId });
throw new NetworkError(403, "Malformed authorization token");
dhr-verma marked this conversation as resolved.
Show resolved Hide resolved
}

token = tokenMatch[2];
}

return token;
}

/**
* Implementation of the IHistorian interface that calls out to a REST interface
* @internal
Expand Down
7 changes: 6 additions & 1 deletion server/routerlicious/packages/services-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export {
export { choose, getRandomName } from "./generateNames";
export { GitManager } from "./gitManager";
export { Heap, IHeapComparator } from "./heap";
export { getAuthorizationTokenFromCredentials, Historian, ICredentials } from "./historian";
export {
getAuthorizationTokenFromCredentials,
Historian,
ICredentials,
parseToken,
} from "./historian";
export { IAlfredTenant, ISession } from "./interfaces";
export { promiseTimeout } from "./promiseTimeout";
export { RestLessClient, RestLessFieldNames } from "./restLessClient";
Expand Down
18 changes: 18 additions & 0 deletions server/routerlicious/packages/services-client/src/restWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export class BasicRestWrapper extends RestWrapper {
private readonly getTelemetryContextProperties?: () =>
| Record<string, string | number | boolean>
| undefined,
private readonly refreshTokenIfNeeded?: (
authorizationHeader: RawAxiosRequestHeaders,
) => Promise<RawAxiosRequestHeaders | undefined>,
) {
super(baseurl, defaultQueryString, maxBodyLength, maxContentLength);
}
Expand All @@ -183,6 +186,21 @@ export class BasicRestWrapper extends RestWrapper {
this.getTelemetryContextProperties?.(),
);

// If the request has an Authorization header and a refresh token function is provided, try to refresh the token if needed
if (options.headers?.Authorization && this.refreshTokenIfNeeded) {
const refreshedToken = await this.refreshTokenIfNeeded(options.headers).catch(
(error) => {
debug(`request to ${options.url} failed ${error ? error.message : ""}`);
throw error;
},
);
if (refreshedToken) {
options.headers.Authorization = refreshedToken.Authorization;
// Update the default headers to use the refreshed token
this.defaultHeaders.Authorization = refreshedToken.Authorization;
}
}

return new Promise<T>((resolve, reject) => {
this.axios
.request<T>(options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "ax
import AxiosMockAdapter from "axios-mock-adapter";
import { CorrelationIdHeaderName } from "../constants";
import { BasicRestWrapper } from "../restWrapper";
import { KJUR as jsrsasign } from "jsrsasign";
import { jwtDecode } from "jwt-decode";

describe("BasicRestWrapper", () => {
const baseurl = "https://fake.microsoft.com";
Expand Down Expand Up @@ -893,4 +895,61 @@ describe("BasicRestWrapper", () => {
);
});
});

describe("Token refresh", () => {
it("Token should be refreshed if callback is provided", async () => {
const key = "1234";
const expiredToken = jsrsasign.jws.JWS.sign(
null,
JSON.stringify({ alg: "HS256", typ: "JWT" }),
{ exp: Math.round(new Date().getTime() / 1000) - 100 },
key,
);
const getDefaultHeaders = () => {
return {
Authorization: `Basic ${expiredToken}`,
};
};
const newToken = jsrsasign.jws.JWS.sign(
null,
JSON.stringify({ alg: "HS256", typ: "JWT" }),
{ exp: Math.round(new Date().getTime() / 1000) + 10000 },
key,
);

const refreshTokenIfNeeded = async () => {
const tokenClaims = jwtDecode(expiredToken);
if (tokenClaims.exp < new Date().getTime() / 1000) {
return {
Authorization: `Basic ${newToken}`,
};
} else {
return undefined;
}
};

const rw = new BasicRestWrapper(
baseurl,
{},
maxBodyLength,
maxContentLength,
getDefaultHeaders(),
axiosMock as AxiosInstance,
undefined,
undefined,
undefined,
undefined,
refreshTokenIfNeeded,
);

//act
await rw.get(requestUrl).then(
// tslint:disable-next-line:no-void-expression
() => assert.ok(true),
);

assert.notEqual(rw["defaultHeaders"].Authorization, `Basic ${expiredToken}`);
assert.strictEqual(rw["defaultHeaders"].Authorization, `Basic ${newToken}`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"rootDir": "./src",
"outDir": "./dist",
"composite": true,
"types": ["node"],
},
"include": ["src/**/*"],
}
43 changes: 39 additions & 4 deletions server/routerlicious/packages/services-utils/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import {
DocDeleteScopeType,
getGlobalTimeoutContext,
} from "@fluidframework/server-services-client";
import type {
ICache,
IRevokedTokenChecker,
ITenantManager,
import {
requestWithRetry,
type ICache,
type IRevokedTokenChecker,
type ITenantManager,
} from "@fluidframework/server-services-core";
import type { RequestHandler, Request, Response } from "express";
import type { Provider } from "nconf";
Expand Down Expand Up @@ -180,6 +181,10 @@ function getTokenFromRequest(request: Request): string {
if (!authorizationHeader) {
throw new NetworkError(403, "Missing Authorization header.");
}
return extractTokenFromHeader(authorizationHeader);
}

export function extractTokenFromHeader(authorizationHeader: string): string {
const tokenRegex = /Basic (.+)/;
const tokenMatch = tokenRegex.exec(authorizationHeader);
if (!tokenMatch?.[1]) {
Expand All @@ -188,6 +193,36 @@ function getTokenFromRequest(request: Request): string {
return tokenMatch[1];
}

// Returns true if the token is valid for at least 5 minutes.
export function isTokenValid(token: string): boolean {
const tokenClaims = decode(token) as ITokenClaims;
const lifeTimeMSec = tokenClaims.exp * 1000 - new Date().getTime();
return lifeTimeMSec > 5 * 60 * 1000; // 5 minutes
}

export async function getValidAccessToken(
currentAccessToken: string,
tenantManager: ITenantManager,
tenantId: string,
documentId: string,
scopes: ScopeType[],
lumberProperties: Record<string, any>,
): Promise<string | undefined> {
// If the current token is still valid, return undefined
if (isTokenValid(currentAccessToken)) {
Lumberjack.verbose(`Token is still valid`, lumberProperties);
return undefined;
}
Lumberjack.info(`Refreshing token`, lumberProperties);

const newToken = await requestWithRetry(
async () => tenantManager.signToken(tenantId, documentId, scopes),
`getValidAccessToken_signToken` /* callName */,
lumberProperties /* telemetryProperties */,
);
return newToken;
}

const defaultMaxTokenLifetimeSec = 60 * 60; // 1 hour

/**
Expand Down
3 changes: 3 additions & 0 deletions server/routerlicious/packages/services-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export {
verifyStorageToken,
validateTokenScopeClaims,
verifyToken,
isTokenValid,
extractTokenFromHeader,
getValidAccessToken,
} from "./auth";
export { getBooleanFromConfig, getNumberFromConfig } from "./configUtils";
export { parseBoolean } from "./conversion";
Expand Down
Loading
Loading