From 515de886778bf8a98e2c195d758d24685dd9de46 Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Tue, 11 Aug 2026 11:14:50 -0400 Subject: [PATCH] Add client-side connection-string login for SQL, Tables, and Gremlin SQL, Tables, and Gremlin now sign data-plane requests client-side with the account key and skip the Portal Backend proxy (generatetoken/accessinputmetadata/authorizationtokens). Adds client-side host/account validation mirroring the backend ValidateHostAndAccount, plus a real CosmosClient connectivity probe that gates opening the Data Explorer. Mongo and Cassandra continue to use the encrypted-token proxy path. --- src/Common/Constants.ts | 2 +- src/HostedExplorer.test.tsx | 43 +++++- src/HostedExplorer.tsx | 80 ++++++++-- src/HostedExplorerChildFrame.ts | 8 +- .../Components/ConnectExplorer.test.tsx | 14 +- .../Hosted/Components/ConnectExplorer.tsx | 78 +++++++++- .../Helpers/ConnectionStringParser.test.ts | 21 +++ .../Hosted/Helpers/ConnectionStringParser.ts | 23 +++ src/Platform/Hosted/HostedUtils.test.ts | 142 +++++++++++++++++- src/Platform/Hosted/HostedUtils.ts | 120 +++++++++++++++ src/hooks/useKnockoutExplorer.ts | 24 ++- 11 files changed, 515 insertions(+), 40 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index eb1cb44c8..c4d0e1cd5 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -6,7 +6,7 @@ export class EndpointsRegex { public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com"; - public static readonly table = "TableEndpoint=https://(.*).table.cosmosdb.azure.com"; + public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com"; } export class ApiEndpoints { diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx index 22629c078..b08a10df3 100644 --- a/src/HostedExplorer.test.tsx +++ b/src/HostedExplorer.test.tsx @@ -29,9 +29,15 @@ import { useAADAuth } from "./hooks/useAADAuth"; import { useConfig } from "./hooks/useConfig"; import { useTokenMetadata } from "./hooks/usePortalAccessToken"; import { App } from "./HostedExplorer"; -import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer"; +import { + ConnectExplorer, + fetchEncryptedToken, + validateDirectConnectionStringConnectivity, +} from "./Platform/Hosted/Components/ConnectExplorer"; const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction; +const mockValidateDirectConnectionStringConnectivity = + validateDirectConnectionStringConnectivity as jest.MockedFunction; (ConnectExplorer as jest.Mock).mockImplementation(() =>
); @@ -56,6 +62,7 @@ beforeEach(() => { (useConfig as jest.Mock).mockReturnValue({}); (useTokenMetadata as jest.Mock).mockReturnValue(undefined); mockFetchEncryptedToken.mockResolvedValue("encrypted-token"); + mockValidateDirectConnectionStringConnectivity.mockResolvedValue(undefined); }); const dispatchPostMessage = (data: unknown, origin: string) => { @@ -68,7 +75,7 @@ const FAKE_ACCOUNT_NAME: string = "-FakeAccount-"; const FAKE_KEY: string = ""; describe("HostedExplorer tryCosmosDB postMessage handler", () => { - it("accepts a valid SQL connection string from an allowed origin", async () => { + it("signs a valid SQL connection string client-side without calling the backend", async () => { render(); const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`; @@ -81,7 +88,8 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { await Promise.resolve(); }); - expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); }); it("accepts a valid Mongo connection string from an allowed origin", async () => { @@ -116,7 +124,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { expect(mockFetchEncryptedToken).toHaveBeenCalledWith(cassandraConnStr); }); - it("accepts a valid Table connection string from an allowed origin", async () => { + it("signs a valid Table connection string client-side without calling the backend", async () => { render(); const tableConnStr = `DefaultEndpointsProtocol=https;AccountName=${FAKE_ACCOUNT_NAME};AccountKey=${FAKE_KEY};TableEndpoint=https://${FAKE_ACCOUNT_NAME}.table.cosmosdb.azure.com:443/;`; @@ -129,10 +137,11 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { await Promise.resolve(); }); - expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); }); - it("accepts a valid Gremlin connection string from an allowed origin", async () => { + it("signs a valid Gremlin connection string client-side without calling the backend", async () => { render(); const gremlinConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};ApiKind=Gremlin;`; @@ -145,7 +154,27 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => { await Promise.resolve(); }); - expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); + }); + + it("does not open the Data Explorer when the Cosmos client cannot connect", async () => { + mockValidateDirectConnectionStringConnectivity.mockResolvedValue("Unable to connect to the account."); + const { container } = render(); + + const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`; + + await act(async () => { + dispatchPostMessage( + { type: "tryCosmosDBConnectionString", connectionString: validConnStr }, + "https://cosmos.azure.com", + ); + await Promise.resolve(); + }); + + expect(mockValidateDirectConnectionStringConnectivity).toHaveBeenCalled(); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + expect(container.querySelector('[data-test="DataExplorerFrame"]')).toBeNull(); }); it("rejects messages from a disallowed origin", async () => { diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index 41863fb7d..b4e5a9bcb 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -7,11 +7,15 @@ import ChevronRight from "../images/chevron-right.svg"; import "../less/hostedexplorer.less"; import { AuthType } from "./AuthType"; import { logError } from "./Common/Logger"; -import { DatabaseAccount } from "./Contracts/DataModels"; +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, + fetchEncryptedToken, + validateDirectConnectionStringConnectivity, +} 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"; @@ -19,7 +23,12 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton"; import "./Platform/Hosted/ConnectScreen.less"; import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser"; import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils"; -import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils"; +import { + extractAccountKeyFromConnectionString, + extractMasterKeyfromConnectionString, + isDirectConnectionStringLoginApi, + validateDirectConnectionStringLogin, +} from "./Platform/Hosted/HostedUtils"; import "./Shared/appInsights"; import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils"; import { useAADAuth } from "./hooks/useAADAuth"; @@ -43,6 +52,10 @@ const App: React.FunctionComponent = () => { const [databaseAccount, setDatabaseAccount] = React.useState(); const [authType, setAuthType] = React.useState(encryptedToken ? AuthType.EncryptedToken : undefined); const [connectionString, setConnectionString] = React.useState(); + // For SQL/Tables/Gremlin connection-string login, the account metadata is derived client-side from the + // connection string instead of the Portal Backend, so there is no encrypted token. + const [directLoginMetadata, setDirectLoginMetadata] = React.useState(); + const accountMetadata = encryptedTokenMetadata || directLoginMetadata; const ref = React.useRef(); @@ -54,19 +67,52 @@ const App: React.FunctionComponent = () => { setConnectionString(connStr); if (isResourceTokenConnectionString(connStr)) { setAuthType(AuthType.ResourceToken); - } else { - fetchEncryptedToken(connStr) - .then((token) => { - setEncryptedToken(token); + return; + } + + const metadata = parseConnectionString(connStr); + if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { + // SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so we skip + // the Portal Backend proxy and use the metadata derived from the connection string directly. + // Validate the host and account client-side (mirrors the backend's ValidateHostAndAccount). + const validationError = validateDirectConnectionStringLogin(connStr, metadata); + if (validationError) { + logError( + `Rejected connection string for direct login: ${validationError}`, + "HostedExplorer/connectWithConnectionString", + ); + return; + } + // Only open the view once we confirm the Cosmos client can actually connect. + validateDirectConnectionStringConnectivity(connStr, metadata) + .then((connectivityError) => { + if (connectivityError) { + logError( + `Rejected connection string for direct login: ${connectivityError}`, + "HostedExplorer/connectWithConnectionString", + ); + return; + } + setDirectLoginMetadata(metadata); setAuthType(AuthType.ConnectionString); }) .catch((error) => { logError( - `Failed to connect with connection string: ${error}`, + `Failed to validate connection string for direct login: ${error}`, "HostedExplorer/connectWithConnectionString", ); }); + return; } + + fetchEncryptedToken(connStr) + .then((token) => { + setEncryptedToken(token); + setAuthType(AuthType.ConnectionString); + }) + .catch((error) => { + logError(`Failed to connect with connection string: ${error}`, "HostedExplorer/connectWithConnectionString"); + }); }, [authType], ); @@ -120,8 +166,10 @@ const App: React.FunctionComponent = () => { frameWindow.hostedConfig = { authType: AuthType.ConnectionString, encryptedToken, - encryptedTokenMetadata, - masterKey: extractMasterKeyfromConnectionString(connectionString), + encryptedTokenMetadata: accountMetadata, + masterKey: directLoginMetadata + ? extractAccountKeyFromConnectionString(connectionString) + : extractMasterKeyfromConnectionString(connectionString), }; } else if (authType === AuthType.ResourceToken) { frameWindow.hostedConfig = { @@ -140,7 +188,7 @@ const App: React.FunctionComponent = () => { const showExplorer = (config && isLoggedIn && databaseAccount && !connectionString) || - (encryptedTokenMetadata && encryptedTokenMetadata) || + accountMetadata || (authType === AuthType.ResourceToken && connectionString); return ( @@ -157,7 +205,7 @@ const App: React.FunctionComponent = () => { Microsoft Azure Cosmos DB - {(isLoggedIn || encryptedTokenMetadata?.accountName) && ( + {(isLoggedIn || accountMetadata?.accountName) && ( account separator )} {isLoggedIn && !connectionString && ( @@ -165,9 +213,9 @@ const App: React.FunctionComponent = () => { )} - {(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && ( + {(!isLoggedIn || connectionString) && accountMetadata?.accountName && ( - {encryptedTokenMetadata?.accountName} + {accountMetadata?.accountName} )}
@@ -201,7 +249,9 @@ const App: React.FunctionComponent = () => { > )} {!isLoggedIn && !encryptedTokenMetadata && ( - + )} {isLoggedIn && authFailure && } {isLoggedIn && !authFailure && ( diff --git a/src/HostedExplorerChildFrame.ts b/src/HostedExplorerChildFrame.ts index 2cff6c862..906e9c6f2 100644 --- a/src/HostedExplorerChildFrame.ts +++ b/src/HostedExplorerChildFrame.ts @@ -14,10 +14,12 @@ export interface AAD { export interface ConnectionString { authType: AuthType.ConnectionString; - // Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy - encryptedToken: string; + // SQL, Tables, and Gremlin sign data-plane requests client-side with the master key and do not need the + // Portal Backend proxy, so they carry no encrypted token. Mongo and Cassandra still use the encrypted + // token because their operations go through the Portal Backend proxy. + encryptedToken?: string; encryptedTokenMetadata: AccessInputMetadata; - // Master key is currently only used by Graph API. All other APIs use encrypted tokens and proxy with connection string + // Master key is used for the client-side signing path (SQL, Tables, Gremlin). Mongo/Cassandra leave it undefined. masterKey?: string; } diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx index dc54a5f2a..60ce62c37 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx @@ -12,8 +12,13 @@ it("shows the connect form", () => { const setConnectionString = jest.fn(); const setEncryptedToken = jest.fn(); const setAuthType = jest.fn(); + const setDirectLoginMetadata = jest.fn(); - render(); + render( + , + ); expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull(); fireEvent.click(screen.getByText("Connect to your account with connection string")); expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined(); @@ -25,6 +30,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i const setConnectionString = jest.fn(); const setEncryptedToken = jest.fn(); const setAuthType = jest.fn(); + const setDirectLoginMetadata = jest.fn(); const oldFeatures = userContext.features; const params = new URLSearchParams({ @@ -34,7 +40,11 @@ it("hides the connection string link when feature.disableConnectionStringLogin i const testFeatures = extractFeatures(params); updateUserContext({ features: testFeatures }); - render(); + render( + , + ); expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull(); updateUserContext({ features: oldFeatures }); diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index 768fddbb6..0377fa1ee 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -1,12 +1,20 @@ import { useBoolean } from "@fluentui/react-hooks"; -import { userContext } from "UserContext"; +import { client } from "Common/CosmosClient"; +import { updateUserContext, userContext } from "UserContext"; 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 { parseConnectionString } from "../Helpers/ConnectionStringParser"; import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils"; +import { + extractAccountKeyFromConnectionString, + isDirectConnectionStringLoginApi, + validateDirectConnectionStringLogin, +} from "../HostedUtils"; interface Props { connectionString: string; @@ -14,6 +22,7 @@ interface Props { setEncryptedToken: (token: string) => void; setConnectionString: (connectionString: string) => void; setAuthType: (authType: AuthType) => void; + setDirectLoginMetadata: (metadata: AccessInputMetadata) => void; } export const fetchEncryptedToken = async (connectionString: string): Promise => { @@ -41,12 +50,43 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri return (await response.text()).toLowerCase() === "true"; }; +// Verifies the account key can actually authenticate against the account by making a lightweight read of +// the database account through the same Cosmos client the Data Explorer uses (client-side signing and +// proxy routing). Returns undefined on success, or an error message when the client cannot connect, so +// the caller can block opening the Data Explorer view. +export const validateDirectConnectionStringConnectivity = async ( + connectionString: string, + metadata: AccessInputMetadata, +): Promise => { + const masterKey = extractAccountKeyFromConnectionString(connectionString); + if (!metadata?.documentEndpoint || !masterKey) { + return "Unable to connect to the account with the provided connection string."; + } + + // Configure the client the same way the Data Explorer will, then issue a lightweight authenticated + // request. The Cosmos client signs locally with the master key and routes through the same proxy. + updateUserContext({ + authType: AuthType.ConnectionString, + masterKey, + endpoint: metadata.documentEndpoint, + refreshCosmosClient: true, + }); + + try { + await client().getDatabaseAccount(); + return undefined; + } catch { + return "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable."; + } +}; + export const ConnectExplorer: React.FunctionComponent = ({ setEncryptedToken, login, setAuthType, connectionString, setConnectionString, + setDirectLoginMetadata, }: Props) => { const [isFormVisible, { setTrue: showForm }] = useBoolean(false); const [errorMessage, setErrorMessage] = React.useState(""); @@ -79,9 +119,39 @@ export const ConnectExplorer: React.FunctionComponent = ({ return; } - const encryptedToken = await fetchEncryptedToken(connectionString); - setEncryptedToken(encryptedToken); - setAuthType(AuthType.ConnectionString); + const metadata = parseConnectionString(connectionString); + if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { + // SQL, Tables, and Gremlin sign data-plane requests client-side with the account key, so + // we skip the Portal Backend proxy and use the metadata parsed from the connection string. + // Validate the host and account client-side (mirrors the backend's ValidateHostAndAccount). + const validationError = validateDirectConnectionStringLogin(connectionString, metadata); + if (validationError) { + setErrorMessage(validationError); + return; + } + // Only open the view once we confirm the Cosmos client can actually connect. + const connectivityError = await validateDirectConnectionStringConnectivity( + connectionString, + metadata, + ); + if (connectivityError) { + setErrorMessage(connectivityError); + return; + } + setDirectLoginMetadata(metadata); + setAuthType(AuthType.ConnectionString); + return; + } + + try { + const encryptedToken = await fetchEncryptedToken(connectionString); + setEncryptedToken(encryptedToken); + setAuthType(AuthType.ConnectionString); + } catch (error) { + setErrorMessage( + "Failed to connect using the provided connection string. Please verify it is correct and try again.", + ); + } }} >

Connect to your account with connection string

diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts index 93d76855c..c8ded18db 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts @@ -12,6 +12,8 @@ describe("ConnectionStringParser", () => { expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); + expect(metadata.apiEndpoint).toBeUndefined(); }); it("should parse a valid mongo account connection string", () => { @@ -39,6 +41,8 @@ describe("ConnectionStringParser", () => { expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); + expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`); }); it("should parse a valid table account connection string", () => { @@ -48,6 +52,20 @@ describe("ConnectionStringParser", () => { expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); + // Tables data operations go through the document endpoint, which is constructed from the account name. + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); + expect(metadata.apiEndpoint).toBeUndefined(); + }); + + it("should parse a valid table account connection string using the cosmos.azure.com zone", () => { + const metadata = parseConnectionString( + `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`, + ); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); + expect(metadata.apiEndpoint).toBeUndefined(); }); it("should parse a valid cassandra account connection string", () => { @@ -57,6 +75,9 @@ describe("ConnectionStringParser", () => { expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); + // Cassandra still uses the Portal Backend proxy, so no client-side endpoints are constructed. + expect(metadata.documentEndpoint).toBeUndefined(); + expect(metadata.apiEndpoint).toBeUndefined(); }); it("should fail to parse an invalid connection string", () => { diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 6c8db5f96..74421a8ee 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -1,6 +1,12 @@ import * as Constants from "../../../Common/Constants"; import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; +// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's +// accessinputmetadata API constructs from the account name for SQL, Tables, and Gremlin accounts. +const DocumentEndpointZone = "documents.azure.com"; +const GremlinEndpointZone = "gremlin.cosmos.azure.com"; +const DnsPort = "443"; + export function parseConnectionString(connectionString: string): AccessInputMetadata { if (connectionString) { try { @@ -38,6 +44,23 @@ export function parseConnectionString(connectionString: string): AccessInputMeta return undefined; } + // For the APIs that log in directly through the Cosmos client (SQL, Tables, Gremlin), derive the + // endpoints client-side instead of Portal Backend's accessinputmetadata call. Tables + // connection strings only carry the table endpoint, so the document endpoint is always constructed + // from the account name. Gremlin also needs its graph endpoint for websocket queries. + if (accessInput.accountName) { + if ( + accessInput.apiKind === ApiKind.SQL || + accessInput.apiKind === ApiKind.Table || + accessInput.apiKind === ApiKind.Graph + ) { + accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`; + if (accessInput.apiKind === ApiKind.Graph) { + accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`; + } + } + } + return accessInput; } catch (error) { return undefined; diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts index 1b8ddd666..a34b71236 100644 --- a/src/Platform/Hosted/HostedUtils.test.ts +++ b/src/Platform/Hosted/HostedUtils.test.ts @@ -1,5 +1,11 @@ -import { AccessInputMetadata } from "../../Contracts/DataModels"; -import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils"; +import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels"; +import { + extractAccountKeyFromConnectionString, + extractEndpointHostFromConnectionString, + getDatabaseAccountPropertiesFromMetadata, + isDirectConnectionStringLoginApi, + validateDirectConnectionStringLogin, +} from "./HostedUtils"; describe("getDatabaseAccountPropertiesFromMetadata", () => { it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => { @@ -30,3 +36,135 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => { }); }); }); + +describe("extractAccountKeyFromConnectionString", () => { + const mockAccountName = "Test"; + const mockKey = "abc123+/=someKey=="; + + it("extracts the account key from a SQL connection string", () => { + expect( + extractAccountKeyFromConnectionString( + `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`, + ), + ).toBe(mockKey); + }); + + it("extracts the account key from a Table connection string", () => { + expect( + extractAccountKeyFromConnectionString( + `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`, + ), + ).toBe(mockKey); + }); + + it("extracts the account key from a Gremlin connection string", () => { + expect( + extractAccountKeyFromConnectionString( + `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`, + ), + ).toBe(mockKey); + }); + + it("returns undefined when there is no account key", () => { + expect( + extractAccountKeyFromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`), + ).toBeUndefined(); + }); + + it("returns undefined for an empty connection string", () => { + expect(extractAccountKeyFromConnectionString("")).toBeUndefined(); + }); +}); + +describe("isDirectConnectionStringLoginApi", () => { + it("returns true for SQL, Table, and Graph", () => { + expect(isDirectConnectionStringLoginApi(ApiKind.SQL)).toBe(true); + expect(isDirectConnectionStringLoginApi(ApiKind.Table)).toBe(true); + expect(isDirectConnectionStringLoginApi(ApiKind.Graph)).toBe(true); + }); + + it("returns false for Mongo and Cassandra, which require the Portal Backend proxy", () => { + expect(isDirectConnectionStringLoginApi(ApiKind.MongoDB)).toBe(false); + expect(isDirectConnectionStringLoginApi(ApiKind.MongoDBCompute)).toBe(false); + expect(isDirectConnectionStringLoginApi(ApiKind.Cassandra)).toBe(false); + }); +}); + +describe("extractEndpointHostFromConnectionString", () => { + it("extracts the host from a SQL/Gremlin AccountEndpoint", () => { + expect( + extractEndpointHostFromConnectionString( + "AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=key==;", + ), + ).toBe("my-account.documents.azure.com"); + }); + + it("extracts the host from a Table TableEndpoint", () => { + expect( + extractEndpointHostFromConnectionString( + "DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=key==;TableEndpoint=https://my-account.table.cosmos.azure.com:443/;", + ), + ).toBe("my-account.table.cosmos.azure.com"); + }); + + it("returns undefined when no endpoint host is present", () => { + expect(extractEndpointHostFromConnectionString("AccountName=my-account;AccountKey=key==;")).toBeUndefined(); + }); +}); + +describe("validateDirectConnectionStringLogin", () => { + const mockKey = "abc123+/=someKey=="; + const sqlMetadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata; + const tableMetadata = { accountName: "my-account", apiKind: ApiKind.Table } as AccessInputMetadata; + const gremlinMetadata = { accountName: "my-account", apiKind: ApiKind.Graph } as AccessInputMetadata; + + it("returns undefined for a valid SQL connection string", () => { + const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`; + expect(validateDirectConnectionStringLogin(connectionString, sqlMetadata)).toBeUndefined(); + }); + + it("returns undefined for a valid Table connection string using the cosmos.azure.com zone", () => { + const connectionString = `DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=${mockKey};TableEndpoint=https://my-account.table.cosmos.azure.com:443/;`; + expect(validateDirectConnectionStringLogin(connectionString, tableMetadata)).toBeUndefined(); + }); + + it("returns undefined for a valid Gremlin connection string", () => { + const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`; + expect(validateDirectConnectionStringLogin(connectionString, gremlinMetadata)).toBeUndefined(); + }); + + it("rejects an empty connection string", () => { + expect(validateDirectConnectionStringLogin("", {} as AccessInputMetadata)).toBe("Connection string is missing."); + }); + + it("rejects when the account name is missing from metadata", () => { + const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`; + expect(validateDirectConnectionStringLogin(connectionString, {} as AccessInputMetadata)).toBe( + "Account name is missing from the connection string.", + ); + }); + + it("rejects a host that is not in an allowlisted zone", () => { + // Point the endpoint host at an untrusted zone while keeping a valid parsed account name. + const tamperedConnectionString = `AccountEndpoint=https://my-account.evil.example.com:443/;AccountKey=${mockKey};`; + expect(validateDirectConnectionStringLogin(tamperedConnectionString, sqlMetadata)).toBe( + "Endpoint host is not allowed.", + ); + }); + + it("rejects when the account name does not match the endpoint host label", () => { + const connectionString = `AccountEndpoint=https://real-account.documents.azure.com:443/;AccountKey=${mockKey};`; + const metadata = { accountName: "different-account", apiKind: ApiKind.SQL } as AccessInputMetadata; + expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe( + "Account name does not match the endpoint host.", + ); + }); + + it("rejects when the account key is missing", () => { + const connectionString = "AccountEndpoint=https://my-account.documents.azure.com:443/;"; + const metadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata; + expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe( + "Account key is missing from the connection string.", + ); + }); +}); diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts index f39e318b1..5801f8a4a 100644 --- a/src/Platform/Hosted/HostedUtils.ts +++ b/src/Platform/Hosted/HostedUtils.ts @@ -45,3 +45,123 @@ export function extractMasterKeyfromConnectionString(connectionString: string): const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$"); return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined; } + +// 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 extractAccountKeyFromConnectionString(connectionString: string): string | undefined { + const matchedParts = connectionString?.match(/AccountKey=([^;]*)/); + return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined; +} + +// SQL, Tables, and Gremlin can sign data-plane requests client-side with the account key, so they do +// not need the Portal Backend proxy for connection-string login. Mongo and Cassandra still require the +// proxy because they use wire protocols the browser cannot speak directly. +export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean { + return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph; +} + +// DNS zones a SQL, Tables, or Gremlin connection-string endpoint host is allowed to belong to. Mirrors +// the allowlist enforced by the Portal Backend's +// ConnectionStringAccessProvider.ValidateHostAndAccount for the direct-login APIs. Both the newer +// `table.cosmos.azure.com` and legacy `table.cosmosdb.azure.com` Tables zones are accepted. +const directLoginAllowlistedEndpointZones = [ + "documents.azure.com", + "table.cosmos.azure.com", + "table.cosmosdb.azure.com", +]; + +// Returns the substring of `value` starting at `startIndex` up to the first ':', '/', or '?'. +// Mirrors ExtractHostToken in the Portal Backend. +function extractHostToken(value: string, startIndex: number): string { + let end = value.length; + for (let i = startIndex; i < value.length; i++) { + const c = value[i]; + if (c === ":" || c === "/" || c === "?") { + end = i; + break; + } + } + return value.substring(startIndex, end); +} + +// Extracts the endpoint host from a connection string, mirroring ExtractEndpointHost in the Portal +// Backend. Handles AccountEndpoint/TableEndpoint (URI or bare host), HostName, and mongodb:// segments. +export function extractEndpointHostFromConnectionString(connectionString: string): string | undefined { + for (const part of connectionString.split(";")) { + const trimmed = part.trim(); + + if (trimmed.toLowerCase().startsWith("mongodb://")) { + const atIndex = trimmed.indexOf("@"); + if (atIndex >= 0 && atIndex < trimmed.length - 1) { + return extractHostToken(trimmed, atIndex + 1); + } + continue; + } + + const equalsIndex = trimmed.indexOf("="); + if (equalsIndex < 0 || equalsIndex === trimmed.length - 1) { + continue; + } + + const key = trimmed.substring(0, equalsIndex).trim().toLowerCase(); + const value = trimmed.substring(equalsIndex + 1).trim(); + + if (key === "accountendpoint" || key === "tableendpoint") { + try { + return new URL(value).hostname; + } catch { + // Value may be a bare host without a scheme. + return extractHostToken(value, 0); + } + } + + if (key === "hostname") { + return extractHostToken(value, 0); + } + } + + return undefined; +} + +// Client-side equivalent of the Portal Backend's ConnectionStringAccessProvider.ValidateHostAndAccount, +// scoped to the direct-login APIs (SQL, Tables, Gremlin). Ensures the connection string parses to an +// account name, exposes an endpoint host in an allowlisted DNS zone, that the account name matches the +// first DNS label of that host, and that an account key is present. Returns an error message when +// invalid, or undefined when the connection string is valid for direct login. +export function validateDirectConnectionStringLogin( + connectionString: string, + metadata: AccessInputMetadata, +): string | undefined { + if (!connectionString) { + return "Connection string is missing."; + } + + if (!metadata || !metadata.accountName) { + return "Account name is missing from the connection string."; + } + + const host = extractEndpointHostFromConnectionString(connectionString); + if (!host) { + return "Endpoint host is missing from the connection string."; + } + + // The host must belong to one of the allowlisted runtime endpoint zones. + const isAllowlistedHost = directLoginAllowlistedEndpointZones.some((zone) => + host.toLowerCase().endsWith(`.${zone.toLowerCase()}`), + ); + if (!isAllowlistedHost) { + return "Endpoint host is not allowed."; + } + + // The account name must be the first DNS label of the host. + if (host.split(".")[0].toLowerCase() !== metadata.accountName.toLowerCase()) { + return "Account name does not match the endpoint host."; + } + + // Direct login signs requests with the account key, so it must be present in the connection string. + if (!extractAccountKeyFromConnectionString(connectionString)) { + return "Account key is missing from the connection string."; + } + + return undefined; +} diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index d4a4f9555..6c6c1c368 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -467,13 +467,25 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata), tags: {}, }; - updateUserContext({ + if (config.masterKey && !config.encryptedToken) { + // Direct client-side signing path (SQL, Tables, Gremlin). Requests are signed locally with the + // account key via the Cosmos client's tokenProvider, so no Portal Backend proxy or encrypted token + // is required. + updateUserContext({ + authType: AuthType.ConnectionString, + databaseAccount, + masterKey: config.masterKey, + }); + } else { + // Legacy encrypted-token proxy path (Mongo, Cassandra). // For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login - authType: AuthType.EncryptedToken, - accessToken: encodeURIComponent(config.encryptedToken), - databaseAccount, - masterKey: config.masterKey, - }); + updateUserContext({ + authType: AuthType.EncryptedToken, + accessToken: encodeURIComponent(config.encryptedToken), + databaseAccount, + masterKey: config.masterKey, + }); + } const explorer = new Explorer(); return explorer; }