Skip to content

Commit

Permalink
Cherry Pick #21601 into RC.4.0 - TokenFetcher support to get authoriz…
Browse files Browse the repository at this point in the history
…ationHeader (#21669)

Cherry Pick #21601

## Description
There is an assumption throughout the odsp-driver code that tokens are always Bearer tokens. This is not the case. AT_POP tokens could also be used. AT_POP tokens encode url path, query params and http method into the token. This means that the token itself cannot be passed via query params (otherwise that would be a circular dependency).

This PR adds authorizationHeader to TokenResponse as well as accepts request on the TokenFetcher interface.

## Breaking Changes
None, all changes to public interfaces should be backwards compatible.
Behavior changes:
- forceAccessTokenViaAuthorizationHeader is now a no-op.
- ICollabSessionOptions.unauthenticatedUserDisplayName is no longer used or passed through joinSession
  • Loading branch information
aboktor authored Jun 27, 2024
1 parent 2602d94 commit 13e2693
Show file tree
Hide file tree
Showing 23 changed files with 295 additions and 352 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { FiveDaysMs } from '@fluidframework/driver-definitions/internal';
import { IDriverErrorBase } from '@fluidframework/driver-definitions/internal';
import { IResolvedUrl } from '@fluidframework/driver-definitions/internal';

// @internal
export const authHeaderFromTokenResponse: (tokenResponse: string | TokenResponse | null | undefined) => string | null;

// @alpha (undocumented)
export type CacheContentType = "snapshot" | "ops";

Expand Down Expand Up @@ -47,6 +50,7 @@ export interface ICacheEntry extends IEntry {
export interface ICollabSessionOptions {
// @deprecated (undocumented)
forceAccessTokenViaAuthorizationHeader?: boolean;
// @deprecated (undocumented)
unauthenticatedUserDisplayName?: string;
}

Expand Down Expand Up @@ -289,14 +293,19 @@ export type TokenFetcher<T> = (options: T) => Promise<string | TokenResponse | n
export interface TokenFetchOptions {
claims?: string;
refresh: boolean;
readonly request?: {
url: string;
method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
};
tenantId?: string;
}

// @internal
// @internal @deprecated
export const tokenFromResponse: (tokenResponse: string | TokenResponse | null | undefined) => string | null;

// @beta
export interface TokenResponse {
readonly authorizationHeader?: string;
fromCache?: boolean;
token: string;
}
Expand Down
1 change: 1 addition & 0 deletions packages/drivers/odsp-driver-definitions/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface IOpsCachingPolicy {
*/
export interface ICollabSessionOptions {
/**
* @deprecated starting in 2.0-RC3. No longer needed.
* Value indicating the display name for session that admits unauthenticated user.
* This name will be used in attribution associated with edits made by such user.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/drivers/odsp-driver-definitions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {
OdspResourceTokenFetchOptions,
TokenFetcher,
TokenFetchOptions,
authHeaderFromTokenResponse,
tokenFromResponse,
TokenResponse,
} from "./tokenFetch.js";
Expand Down
36 changes: 36 additions & 0 deletions packages/drivers/odsp-driver-definitions/src/tokenFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export interface TokenResponse {
/** Token value */
token: string;

/**
* Authorization header value will be used verbatim when making network call that requires the token.
* If not returned the token value will be assumed to be a Bearer token and will be used to generate the
* Authorization header value in the following format: `Bearer ${token}`.
*/
readonly authorizationHeader?: string;

/**
* Whether or not the token was obtained from local cache.
* @remarks `undefined` indicates that it could not be determined whether or not the token was obtained this way.
Expand Down Expand Up @@ -41,6 +48,14 @@ export interface TokenFetchOptions {
* to use to issue access token.
*/
tenantId?: string;

/**
* Request that will be made using the fetched token.
* - url: full request url, including query params
* - method: method type
* Request info may be encoded into the returned token that the receiver can use to validate that caller is allowed to make specific call.
*/
readonly request?: { url: string; method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT" };
}

/**
Expand Down Expand Up @@ -73,6 +88,7 @@ export type TokenFetcher<T> = (options: T) => Promise<string | TokenResponse | n
* @param tokenResponse - return value for TokenFetcher method
* @returns Token value
* @internal
* @deprecated - Use authHeaderFromTokenResponse instead
*/
export const tokenFromResponse = (
tokenResponse: string | TokenResponse | null | undefined,
Expand All @@ -83,6 +99,25 @@ export const tokenFromResponse = (
? null
: tokenResponse.token;

/**
* Helper method which transforms return value for TokenFetcher method to Authorization header value
* @param tokenResponse - return value for TokenFetcher method
* @returns Authorization header value
* @internal
*/
export const authHeaderFromTokenResponse = (
tokenResponse: string | TokenResponse | null | undefined,
): string | null => {
if (typeof tokenResponse === "object" && tokenResponse?.authorizationHeader !== undefined) {
return tokenResponse.authorizationHeader;
}
const token = tokenFromResponse(tokenResponse);
if (token !== null) {
return `Bearer ${token}`;
}
return null;
};

/**
* Helper method which returns flag indicating whether token response comes from local cache
* @param tokenResponse - return value for TokenFetcher method
Expand All @@ -106,6 +141,7 @@ export const isTokenFromCache = (
export type IdentityType = "Consumer" | "Enterprise";

/**
* @returns Authorization header value
* @internal
*/
export type InstrumentedStorageTokenFetcher = (
Expand Down
2 changes: 1 addition & 1 deletion packages/drivers/odsp-driver/api-report/odsp-driver.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export interface OdspFluidDataStoreLocator extends IOdspUrlParts {
// @internal
export function parseCompactSnapshotResponse(buffer: Uint8Array, logger: ITelemetryLoggerExt): ISnapshotContentsWithProps;

// @alpha
// @alpha @deprecated
export function prefetchLatestSnapshot(resolvedUrl: IResolvedUrl, getStorageToken: TokenFetcher<OdspResourceTokenFetchOptions>, persistedCache: IPersistedCache, forceAccessTokenViaAuthorizationHeader: boolean, logger: ITelemetryBaseLogger, hostSnapshotFetchOptions: ISnapshotOptions | undefined, enableRedeemFallback?: boolean, fetchBinarySnapshotFormat?: boolean, snapshotFormatFetchType?: SnapshotFormatSupportType, odspDocumentServiceFactory?: OdspDocumentServiceFactory): Promise<boolean>;

// @alpha
Expand Down
36 changes: 15 additions & 21 deletions packages/drivers/odsp-driver/src/createFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from "./createNewUtils.js";
import { createOdspUrl } from "./createOdspUrl.js";
import { EpochTracker } from "./epochTracker.js";
import { getUrlAndHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { getHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { OdspDriverUrlResolver } from "./odspDriverUrlResolver.js";
import { getApiRoot } from "./odspUrlHelper.js";
import {
Expand All @@ -47,7 +47,7 @@ const isInvalidFileName = (fileName: string): boolean => {
* Returns resolved url
*/
export async function createNewFluidFile(
getStorageToken: InstrumentedStorageTokenFetcher,
getAuthHeader: InstrumentedStorageTokenFetcher,
newFileInfo: INewFileInfo,
logger: ITelemetryLoggerExt,
createNewSummary: ISummaryTree | undefined,
Expand All @@ -72,16 +72,10 @@ export async function createNewFluidFile(
let summaryHandle: string = "";
let shareLinkInfo: ShareLinkInfoType | undefined;
if (createNewSummary === undefined) {
itemId = await createNewEmptyFluidFile(
getStorageToken,
newFileInfo,
logger,
epochTracker,
forceAccessTokenViaAuthorizationHeader,
);
itemId = await createNewEmptyFluidFile(getAuthHeader, newFileInfo, logger, epochTracker);
} else {
const content = await createNewFluidFileFromSummary(
getStorageToken,
getAuthHeader,
newFileInfo,
logger,
createNewSummary,
Expand Down Expand Up @@ -160,11 +154,10 @@ function extractShareLinkData(
}

export async function createNewEmptyFluidFile(
getStorageToken: InstrumentedStorageTokenFetcher,
getAuthHeader: InstrumentedStorageTokenFetcher,
newFileInfo: INewFileInfo,
logger: ITelemetryLoggerExt,
epochTracker: EpochTracker,
forceAccessTokenViaAuthorizationHeader: boolean,
): Promise<string> {
const filePath = newFileInfo.filePath ? encodeURIComponent(`/${newFileInfo.filePath}`) : "";
// add .tmp extension to empty file (host is expected to rename)
Expand All @@ -174,17 +167,18 @@ export async function createNewEmptyFluidFile(
}/items/root:/${filePath}/${encodedFilename}:/[email protected]=rename&select=id,name,parentReference`;

return getWithRetryForTokenRefresh(async (options) => {
const storageToken = await getStorageToken(options, "CreateNewFile");
const url = initialUrl;
const method = "PUT";
const authHeader = await getAuthHeader(
{ ...options, request: { url, method } },
"CreateNewFile",
);

return PerformanceEvent.timedExecAsync(
logger,
{ eventName: "createNewEmptyFile" },
async (event) => {
const { url, headers } = getUrlAndHeadersWithAuth(
initialUrl,
storageToken,
forceAccessTokenViaAuthorizationHeader,
);
const headers = getHeadersWithAuth(authHeader);
headers["Content-Type"] = "application/json";

const fetchResponse = await runWithRetry(
Expand All @@ -194,7 +188,7 @@ export async function createNewEmptyFluidFile(
{
body: undefined,
headers,
method: "PUT",
method,
},
"createFile",
),
Expand Down Expand Up @@ -222,7 +216,7 @@ export async function createNewEmptyFluidFile(
}

export async function createNewFluidFileFromSummary(
getStorageToken: InstrumentedStorageTokenFetcher,
getAuthHeader: InstrumentedStorageTokenFetcher,
newFileInfo: INewFileInfo,
logger: ITelemetryLoggerExt,
createNewSummary: ISummaryTree,
Expand All @@ -246,7 +240,7 @@ export async function createNewFluidFileFromSummary(

return createNewFluidContainerCore<ICreateFileResponse>({
containerSnapshot,
getStorageToken,
getAuthHeader,
logger,
initialUrl,
forceAccessTokenViaAuthorizationHeader,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { IExistingFileInfo, createCacheSnapshotKey } from "./odspUtils.js";
* "alternative file partition" where the main File stub is an ASPX page.
*/
export async function createNewContainerOnExistingFile(
getStorageToken: InstrumentedStorageTokenFetcher,
getAuthHeader: InstrumentedStorageTokenFetcher,
fileInfo: IExistingFileInfo,
logger: ITelemetryLoggerExt,
createNewSummary: ISummaryTree | undefined,
Expand All @@ -62,7 +62,7 @@ export async function createNewContainerOnExistingFile(

const { id: summaryHandle } = await createNewFluidContainerCore<IWriteSummaryResponse>({
containerSnapshot,
getStorageToken,
getAuthHeader,
logger,
initialUrl,
forceAccessTokenViaAuthorizationHeader,
Expand Down
56 changes: 32 additions & 24 deletions packages/drivers/odsp-driver/src/createNewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
OdspSummaryTreeValue,
} from "./contracts.js";
import { EpochTracker, FetchType } from "./epochTracker.js";
import { getUrlAndHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { getHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
import { getWithRetryForTokenRefresh, maxUmpPostBodySize } from "./odspUtils.js";
import { runWithRetry } from "./retryUtils.js";

Expand Down Expand Up @@ -200,7 +200,7 @@ function convertSummaryToSnapshotTreeForCreateNew(summary: ISummaryTree): IOdspS

export async function createNewFluidContainerCore<T>(args: {
containerSnapshot: IOdspSummaryPayload;
getStorageToken: InstrumentedStorageTokenFetcher;
getAuthHeader: InstrumentedStorageTokenFetcher;
logger: ITelemetryLoggerExt;
initialUrl: string;
forceAccessTokenViaAuthorizationHeader: boolean;
Expand All @@ -211,19 +211,16 @@ export async function createNewFluidContainerCore<T>(args: {
}): Promise<T> {
const {
containerSnapshot,
getStorageToken,
getAuthHeader,
logger,
initialUrl,
forceAccessTokenViaAuthorizationHeader,
epochTracker,
telemetryName,
fetchType,
validateResponseCallback,
} = args;

return getWithRetryForTokenRefresh(async (options) => {
const storageToken = await getStorageToken(options, telemetryName);

return PerformanceEvent.timedExecAsync(
logger,
{ eventName: telemetryName },
Expand All @@ -233,31 +230,42 @@ export async function createNewFluidContainerCore<T>(args: {
let headers: { [index: string]: string };
let addInBody = false;
const formBoundary = uuid();
let postBody = `--${formBoundary}\r\n`;
postBody += `Authorization: Bearer ${storageToken}\r\n`;
postBody += `X-HTTP-Method-Override: POST\r\n`;
postBody += `Content-Type: application/json\r\n`;
postBody += `_post: 1\r\n`;
postBody += `\r\n${snapshotBody}\r\n`;
postBody += `\r\n--${formBoundary}--`;
const urlObj = new URL(initialUrl);
urlObj.searchParams.set("ump", "1");
const authInBodyUrl = urlObj.href;
const method = "POST";
const authHeader = await getAuthHeader(
{ ...options, request: { url: authInBodyUrl, method } },
telemetryName,
);
const postBodyWithAuth =
`--${formBoundary}\r\n` +
`Authorization: ${authHeader}\r\n` +
`X-HTTP-Method-Override: POST\r\n` +
`Content-Type: application/json\r\n` +
`_post: 1\r\n` +
`\r\n${snapshotBody}\r\n` +
`\r\n--${formBoundary}--`;

if (postBody.length <= maxUmpPostBodySize) {
const urlObj = new URL(initialUrl);
urlObj.searchParams.set("ump", "1");
url = urlObj.href;
let postBody = snapshotBody;
if (
postBodyWithAuth.length <= maxUmpPostBodySize &&
authHeader?.startsWith("Bearer")
) {
url = authInBodyUrl;
headers = {
"Content-Type": `multipart/form-data;boundary=${formBoundary}`,
};
addInBody = true;
postBody = postBodyWithAuth;
} else {
const parts = getUrlAndHeadersWithAuth(
initialUrl,
storageToken,
forceAccessTokenViaAuthorizationHeader,
url = initialUrl;
const authHeaderNoUmp = await getAuthHeader(
{ ...options, request: { url, method } },
telemetryName,
);
url = parts.url;
headers = {
...parts.headers,
...getHeadersWithAuth(authHeaderNoUmp),
"Content-Type": "application/json",
};
postBody = snapshotBody;
Expand All @@ -270,7 +278,7 @@ export async function createNewFluidContainerCore<T>(args: {
{
body: postBody,
headers,
method: "POST",
method,
},
fetchType,
addInBody,
Expand Down
Loading

0 comments on commit 13e2693

Please sign in to comment.