Move Portal Backend calls into a PortalBackendClient helper

fetchEncryptedToken and isAccountRestrictedForConnectionStringLogin lived in ConnectExplorer.tsx, so HostedExplorer imported a network call from a React component. Merge them with fetchAccessData into src/Platform/Hosted/Helpers/PortalBackendClient.ts along with the PortalBackendError type, leaving ConnectExplorer as UI only.
This commit is contained in:
Asier Isayas
2026-08-19 13:03:03 -07:00
parent 9dccd066c2
commit 9e39285340
11 changed files with 124 additions and 71 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
jest.mock("./hooks/useAADAuth");
jest.mock("./hooks/useConfig");
jest.mock("./Platform/Hosted/Helpers/PortalAccessData");
jest.mock("./Platform/Hosted/Helpers/PortalBackendClient");
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
jest.mock("./Shared/appInsights");
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
@@ -28,8 +28,8 @@ import React from "react";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { App } from "./HostedExplorer";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { fetchAccessData } from "./Platform/Hosted/Helpers/PortalAccessData";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
import { fetchAccessData, fetchEncryptedToken } from "./Platform/Hosted/Helpers/PortalBackendClient";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
+7 -4
View File
@@ -12,16 +12,19 @@ import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { MeControl } from "./Platform/Hosted/Components/MeControl";
import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import "./Platform/Hosted/ConnectScreen.less";
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
import { fetchAccessData } from "./Platform/Hosted/Helpers/PortalAccessData";
import { fetchAccessData, fetchEncryptedToken } from "./Platform/Hosted/Helpers/PortalBackendClient";
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "./Platform/Hosted/HostedUtils";
import {
extractMasterKeyFromDirectLoginConnectionString,
isDirectConnectionStringLoginApi,
} from "./Platform/Hosted/HostedUtils";
import "./Shared/appInsights";
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
import { useAADAuth } from "./hooks/useAADAuth";
@@ -140,7 +143,7 @@ const App: React.FunctionComponent = () => {
authType: AuthType.ConnectionString,
encryptedToken,
encryptedTokenMetadata: accountMetadata,
masterKey: extractMasterKeyfromConnectionString(connectionString),
masterKey: extractMasterKeyFromDirectLoginConnectionString(connectionString),
};
} else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = {
@@ -5,10 +5,14 @@ import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType";
import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { isAuthorizationError } from "../../../Utils/AuthorizationUtils";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
import {
fetchEncryptedToken,
isAccountRestrictedForConnectionStringLogin,
PortalBackendError,
} from "../Helpers/PortalBackendClient";
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
import { isDirectConnectionStringLoginApi } from "../HostedUtils";
@@ -21,36 +25,6 @@ interface Props {
setAccountMetadata: (metadata: AccessInputMetadata) => void;
}
// Turns a failed Portal Backend response into an error that carries the message returned by the service.
const errorFromResponse = async (response: Response): Promise<Error> =>
new Error((await response.text()) || response.statusText);
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
headers.append(HttpHeaders.authorization, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw await errorFromResponse(response);
}
const encryptedTokenResponse: string = await response.json();
return decodeURIComponent(encryptedTokenResponse);
};
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw await errorFromResponse(response);
}
return (await response.text()).toLowerCase() === "true";
};
export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken,
login,
@@ -109,7 +83,16 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
} catch (error) {
setErrorMessage(getErrorMessage(error));
// A 401 or 403 means the credentials or Portal Backend were not authorized.
// Any other failure is on the backend, so rather than block the login
// we sign in with the metadata parsed from the connection string.
if (!metadata || (error instanceof PortalBackendError && isAuthorizationError(error.statusCode))) {
setErrorMessage(getErrorMessage(error));
return;
}
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
}
}}
>
@@ -48,7 +48,7 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined;
}
// Tables connection strings only carry the table endpoint, so the document endpoint that data
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
// operations go through has to be derived from the account name. Gremlin accounts additionally
// need the Gremlin endpoint, which is never part of the connection string.
if (accessInput.accountName) {
@@ -1,19 +0,0 @@
import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
headers.append(HttpHeaders.authorization, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
@@ -0,0 +1,63 @@
import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
// A failed Portal Backend response. Carries the status so callers can tell a rejected connection string
// from a service failure.
export class PortalBackendError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
// Set the prototype explicitly so `instanceof` works.
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
Object.setPrototypeOf(this, PortalBackendError.prototype);
}
static async fromResponse(response: Response): Promise<PortalBackendError> {
return new PortalBackendError(await response.text(), response.status);
}
}
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
headers.append(HttpHeaders.authorization, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
export async function fetchEncryptedToken(connectionString: string): Promise<string> {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
headers.append(HttpHeaders.authorization, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw await PortalBackendError.fromResponse(response);
}
const encryptedTokenResponse: string = await response.json();
return decodeURIComponent(encryptedTokenResponse);
}
export async function isAccountRestrictedForConnectionStringLogin(connectionString: string): Promise<boolean> {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw await PortalBackendError.fromResponse(response);
}
return (await response.text()).toLowerCase() === "true";
}
+9 -7
View File
@@ -1,6 +1,6 @@
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
import {
extractMasterKeyfromConnectionString,
extractMasterKeyFromDirectLoginConnectionString,
getDatabaseAccountPropertiesFromMetadata,
isDirectConnectionStringLoginApi,
} from "./HostedUtils";
@@ -35,13 +35,13 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
});
});
describe("extractMasterKeyfromConnectionString", () => {
describe("extractMasterKeyFromDirectLoginConnectionString", () => {
const mockAccountName = "Test";
const mockKey = "abc123+/=someKey==";
it("extracts the account key from a SQL connection string", () => {
expect(
extractMasterKeyfromConnectionString(
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
),
).toBe(mockKey);
@@ -49,7 +49,7 @@ describe("extractMasterKeyfromConnectionString", () => {
it("extracts the account key from a Table connection string", () => {
expect(
extractMasterKeyfromConnectionString(
extractMasterKeyFromDirectLoginConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
),
).toBe(mockKey);
@@ -57,7 +57,7 @@ describe("extractMasterKeyfromConnectionString", () => {
it("extracts the account key from a Gremlin connection string", () => {
expect(
extractMasterKeyfromConnectionString(
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
),
).toBe(mockKey);
@@ -65,12 +65,14 @@ describe("extractMasterKeyfromConnectionString", () => {
it("returns undefined when there is no account key", () => {
expect(
extractMasterKeyfromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`),
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`,
),
).toBeUndefined();
});
it("returns undefined for an empty connection string", () => {
expect(extractMasterKeyfromConnectionString("")).toBeUndefined();
expect(extractMasterKeyFromDirectLoginConnectionString("")).toBeUndefined();
});
});
+4 -3
View File
@@ -40,9 +40,10 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC
return AccountKind.GlobalDocumentDB;
}
// Extracts the account key from any Cosmos connection string. The account key value cannot contain a
// semicolon, so we capture everything up to the next connection-string delimiter.
export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined {
// Returns the master key carried by SQL, Table, and Gremlin connection strings. Mongo and
// Cassandra do not use an AccountKey token, so they must not be passed here. The key value cannot
// contain a semicolon, so we capture everything up to the next delimiter.
export function extractMasterKeyFromDirectLoginConnectionString(connectionString: string): string | undefined {
const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
}
+16
View File
@@ -76,6 +76,22 @@ describe("AuthorizationUtils", () => {
});
});
describe("isAuthorizationError()", () => {
it("should return true for 401 and 403", () => {
expect(AuthorizationUtils.isAuthorizationError(401)).toBe(true);
expect(AuthorizationUtils.isAuthorizationError(403)).toBe(true);
});
it("should return false for other statuses", () => {
expect(AuthorizationUtils.isAuthorizationError(404)).toBe(false);
expect(AuthorizationUtils.isAuthorizationError(500)).toBe(false);
});
it("should return false when there is no status", () => {
expect(AuthorizationUtils.isAuthorizationError(undefined)).toBe(false);
});
});
describe("decryptJWTToken()", () => {
it("should throw an error if token is undefined", () => {
expect(() => AuthorizationUtils.decryptJWTToken(undefined)).toThrow();
+4
View File
@@ -28,6 +28,10 @@ export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMet
}
}
export function isAuthorizationError(statusCode: number): boolean {
return statusCode === Constants.HttpStatusCodes.Unauthorized || statusCode === Constants.HttpStatusCodes.Forbidden;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function decryptJWTToken(token: string) {
if (!token) {
+1 -1
View File
@@ -66,7 +66,7 @@
"./src/Platform/Hosted/Components/MeControl.test.tsx",
"./src/Platform/Hosted/Components/MeControl.tsx",
"./src/Platform/Hosted/Components/SignInButton.tsx",
"./src/Platform/Hosted/Helpers/PortalAccessData.ts",
"./src/Platform/Hosted/Helpers/PortalBackendClient.ts",
"./src/Platform/Hosted/HostedUtils.test.ts",
"./src/Platform/Hosted/HostedUtils.ts",
"./src/Platform/Hosted/extractFeatures.test.ts",