From 9e3928534090e8f3d524d93c17551f3136611f29 Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Wed, 19 Aug 2026 13:03:03 -0700 Subject: [PATCH] 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. --- src/HostedExplorer.test.tsx | 6 +- src/HostedExplorer.tsx | 11 ++-- .../Hosted/Components/ConnectExplorer.tsx | 49 +++++---------- .../Hosted/Helpers/ConnectionStringParser.ts | 2 +- .../Hosted/Helpers/PortalAccessData.ts | 19 ------ .../Hosted/Helpers/PortalBackendClient.ts | 63 +++++++++++++++++++ src/Platform/Hosted/HostedUtils.test.ts | 16 ++--- src/Platform/Hosted/HostedUtils.ts | 7 ++- src/Utils/AuthorizationUtils.test.ts | 16 +++++ src/Utils/AuthorizationUtils.ts | 4 ++ tsconfig.strict.json | 2 +- 11 files changed, 124 insertions(+), 71 deletions(-) delete mode 100644 src/Platform/Hosted/Helpers/PortalAccessData.ts create mode 100644 src/Platform/Hosted/Helpers/PortalBackendClient.ts diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx index 71b4df9c0..4043f388b 100644 --- a/src/HostedExplorer.test.tsx +++ b/src/HostedExplorer.test.tsx @@ -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; diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index d764c3635..65672e030 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -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 = { diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index b0b474ded..6e0fdd135 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -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 => - new Error((await response.text()) || response.statusText); - -export const fetchEncryptedToken = async (connectionString: string): Promise => { - 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 => { - 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 = ({ setEncryptedToken, login, @@ -109,7 +83,16 @@ export const ConnectExplorer: React.FunctionComponent = ({ 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); } }} > diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 3f7954e29..44be7e82a 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -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) { diff --git a/src/Platform/Hosted/Helpers/PortalAccessData.ts b/src/Platform/Hosted/Helpers/PortalAccessData.ts deleted file mode 100644 index ac271f17e..000000000 --- a/src/Platform/Hosted/Helpers/PortalAccessData.ts +++ /dev/null @@ -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 { - 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)); -} diff --git a/src/Platform/Hosted/Helpers/PortalBackendClient.ts b/src/Platform/Hosted/Helpers/PortalBackendClient.ts new file mode 100644 index 000000000..0edf9b4ab --- /dev/null +++ b/src/Platform/Hosted/Helpers/PortalBackendClient.ts @@ -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 { + return new PortalBackendError(await response.text(), response.status); + } +} + +export async function fetchAccessData(portalToken: string): Promise { + 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 { + 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 { + 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"; +} diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts index e6f1e4bfd..d199aba0f 100644 --- a/src/Platform/Hosted/HostedUtils.test.ts +++ b/src/Platform/Hosted/HostedUtils.test.ts @@ -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(); }); }); diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts index e4f35e99a..f900548cc 100644 --- a/src/Platform/Hosted/HostedUtils.ts +++ b/src/Platform/Hosted/HostedUtils.ts @@ -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; } diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index fd361ac22..6c9cb0f48 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -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(); diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 0938c6f33..40048e241 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -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) { diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 47d6c9c41..eef27632b 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -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",