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.
This commit is contained in:
Asier Isayas
2026-08-11 11:14:50 -04:00
parent cdc5569a34
commit 515de88677
11 changed files with 515 additions and 40 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export class EndpointsRegex {
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
public static readonly sql = "AccountEndpoint=https://(.*).documents.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 { export class ApiEndpoints {
+36 -7
View File
@@ -29,9 +29,15 @@ import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig"; import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken"; import { useTokenMetadata } from "./hooks/usePortalAccessToken";
import { App } from "./HostedExplorer"; 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<typeof fetchEncryptedToken>; const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
const mockValidateDirectConnectionStringConnectivity =
validateDirectConnectionStringConnectivity as jest.MockedFunction<typeof validateDirectConnectionStringConnectivity>;
(ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />); (ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />);
@@ -56,6 +62,7 @@ beforeEach(() => {
(useConfig as jest.Mock).mockReturnValue({}); (useConfig as jest.Mock).mockReturnValue({});
(useTokenMetadata as jest.Mock).mockReturnValue(undefined); (useTokenMetadata as jest.Mock).mockReturnValue(undefined);
mockFetchEncryptedToken.mockResolvedValue("encrypted-token"); mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
mockValidateDirectConnectionStringConnectivity.mockResolvedValue(undefined);
}); });
const dispatchPostMessage = (data: unknown, origin: string) => { const dispatchPostMessage = (data: unknown, origin: string) => {
@@ -68,7 +75,7 @@ const FAKE_ACCOUNT_NAME: string = "-FakeAccount-";
const FAKE_KEY: string = "<redacted-test-key>"; const FAKE_KEY: string = "<redacted-test-key>";
describe("HostedExplorer tryCosmosDB postMessage handler", () => { 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(<App />); render(<App />);
const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`; 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(); 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 () => { 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); 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(<App />); render(<App />);
const tableConnStr = `DefaultEndpointsProtocol=https;AccountName=${FAKE_ACCOUNT_NAME};AccountKey=${FAKE_KEY};TableEndpoint=https://${FAKE_ACCOUNT_NAME}.table.cosmosdb.azure.com:443/;`; 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(); 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(<App />); render(<App />);
const gremlinConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};ApiKind=Gremlin;`; 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(); 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(<App />);
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 () => { it("rejects messages from a disallowed origin", async () => {
+66 -16
View File
@@ -7,11 +7,15 @@ import ChevronRight from "../images/chevron-right.svg";
import "../less/hostedexplorer.less"; import "../less/hostedexplorer.less";
import { AuthType } from "./AuthType"; import { AuthType } from "./AuthType";
import { logError } from "./Common/Logger"; import { logError } from "./Common/Logger";
import { DatabaseAccount } from "./Contracts/DataModels"; import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less"; import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame"; import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher"; 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 { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton"; import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { MeControl } from "./Platform/Hosted/Components/MeControl"; import { MeControl } from "./Platform/Hosted/Components/MeControl";
@@ -19,7 +23,12 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import "./Platform/Hosted/ConnectScreen.less"; import "./Platform/Hosted/ConnectScreen.less";
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser"; import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils"; 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 "./Shared/appInsights";
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils"; import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
import { useAADAuth } from "./hooks/useAADAuth"; import { useAADAuth } from "./hooks/useAADAuth";
@@ -43,6 +52,10 @@ const App: React.FunctionComponent = () => {
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>(); const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined); const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
const [connectionString, setConnectionString] = React.useState<string>(); const [connectionString, setConnectionString] = React.useState<string>();
// 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<AccessInputMetadata>();
const accountMetadata = encryptedTokenMetadata || directLoginMetadata;
const ref = React.useRef<HTMLIFrameElement>(); const ref = React.useRef<HTMLIFrameElement>();
@@ -54,19 +67,52 @@ const App: React.FunctionComponent = () => {
setConnectionString(connStr); setConnectionString(connStr);
if (isResourceTokenConnectionString(connStr)) { if (isResourceTokenConnectionString(connStr)) {
setAuthType(AuthType.ResourceToken); setAuthType(AuthType.ResourceToken);
} else { 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 validate connection string for direct login: ${error}`,
"HostedExplorer/connectWithConnectionString",
);
});
return;
}
fetchEncryptedToken(connStr) fetchEncryptedToken(connStr)
.then((token) => { .then((token) => {
setEncryptedToken(token); setEncryptedToken(token);
setAuthType(AuthType.ConnectionString); setAuthType(AuthType.ConnectionString);
}) })
.catch((error) => { .catch((error) => {
logError( logError(`Failed to connect with connection string: ${error}`, "HostedExplorer/connectWithConnectionString");
`Failed to connect with connection string: ${error}`,
"HostedExplorer/connectWithConnectionString",
);
}); });
}
}, },
[authType], [authType],
); );
@@ -120,8 +166,10 @@ const App: React.FunctionComponent = () => {
frameWindow.hostedConfig = { frameWindow.hostedConfig = {
authType: AuthType.ConnectionString, authType: AuthType.ConnectionString,
encryptedToken, encryptedToken,
encryptedTokenMetadata, encryptedTokenMetadata: accountMetadata,
masterKey: extractMasterKeyfromConnectionString(connectionString), masterKey: directLoginMetadata
? extractAccountKeyFromConnectionString(connectionString)
: extractMasterKeyfromConnectionString(connectionString),
}; };
} else if (authType === AuthType.ResourceToken) { } else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = { frameWindow.hostedConfig = {
@@ -140,7 +188,7 @@ const App: React.FunctionComponent = () => {
const showExplorer = const showExplorer =
(config && isLoggedIn && databaseAccount && !connectionString) || (config && isLoggedIn && databaseAccount && !connectionString) ||
(encryptedTokenMetadata && encryptedTokenMetadata) || accountMetadata ||
(authType === AuthType.ResourceToken && connectionString); (authType === AuthType.ResourceToken && connectionString);
return ( return (
@@ -157,7 +205,7 @@ const App: React.FunctionComponent = () => {
Microsoft Azure Microsoft Azure
</span> </span>
<span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span> <span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span>
{(isLoggedIn || encryptedTokenMetadata?.accountName) && ( {(isLoggedIn || accountMetadata?.accountName) && (
<img className="chevronRight" src={ChevronRight} alt="account separator" /> <img className="chevronRight" src={ChevronRight} alt="account separator" />
)} )}
{isLoggedIn && !connectionString && ( {isLoggedIn && !connectionString && (
@@ -165,9 +213,9 @@ const App: React.FunctionComponent = () => {
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} /> <AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
</span> </span>
)} )}
{(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && ( {(!isLoggedIn || connectionString) && accountMetadata?.accountName && (
<span className="accountSwitchComponentContainer"> <span className="accountSwitchComponentContainer">
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span> <span className="accountNameHeader">{accountMetadata?.accountName}</span>
</span> </span>
)} )}
</div> </div>
@@ -201,7 +249,9 @@ const App: React.FunctionComponent = () => {
></iframe> ></iframe>
)} )}
{!isLoggedIn && !encryptedTokenMetadata && ( {!isLoggedIn && !encryptedTokenMetadata && (
<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} /> <ConnectExplorer
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
/>
)} )}
{isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />} {isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />}
{isLoggedIn && !authFailure && ( {isLoggedIn && !authFailure && (
+5 -3
View File
@@ -14,10 +14,12 @@ export interface AAD {
export interface ConnectionString { export interface ConnectionString {
authType: AuthType.ConnectionString; authType: AuthType.ConnectionString;
// Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy // SQL, Tables, and Gremlin sign data-plane requests client-side with the master key and do not need the
encryptedToken: string; // 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; 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; masterKey?: string;
} }
@@ -12,8 +12,13 @@ it("shows the connect form", () => {
const setConnectionString = jest.fn(); const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn(); const setEncryptedToken = jest.fn();
const setAuthType = jest.fn(); const setAuthType = jest.fn();
const setDirectLoginMetadata = jest.fn();
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />); render(
<ConnectExplorer
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
/>,
);
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull(); expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull();
fireEvent.click(screen.getByText("Connect to your account with connection string")); fireEvent.click(screen.getByText("Connect to your account with connection string"));
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined(); 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 setConnectionString = jest.fn();
const setEncryptedToken = jest.fn(); const setEncryptedToken = jest.fn();
const setAuthType = jest.fn(); const setAuthType = jest.fn();
const setDirectLoginMetadata = jest.fn();
const oldFeatures = userContext.features; const oldFeatures = userContext.features;
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -34,7 +40,11 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
const testFeatures = extractFeatures(params); const testFeatures = extractFeatures(params);
updateUserContext({ features: testFeatures }); updateUserContext({ features: testFeatures });
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />); render(
<ConnectExplorer
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
/>,
);
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull(); expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
updateUserContext({ features: oldFeatures }); updateUserContext({ features: oldFeatures });
@@ -1,12 +1,20 @@
import { useBoolean } from "@fluentui/react-hooks"; 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 * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg"; import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg"; import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType"; import { AuthType } from "../../../AuthType";
import { HttpHeaders } from "../../../Common/Constants"; import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext"; import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils"; import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
import {
extractAccountKeyFromConnectionString,
isDirectConnectionStringLoginApi,
validateDirectConnectionStringLogin,
} from "../HostedUtils";
interface Props { interface Props {
connectionString: string; connectionString: string;
@@ -14,6 +22,7 @@ interface Props {
setEncryptedToken: (token: string) => void; setEncryptedToken: (token: string) => void;
setConnectionString: (connectionString: string) => void; setConnectionString: (connectionString: string) => void;
setAuthType: (authType: AuthType) => void; setAuthType: (authType: AuthType) => void;
setDirectLoginMetadata: (metadata: AccessInputMetadata) => void;
} }
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => { export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
@@ -41,12 +50,43 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
return (await response.text()).toLowerCase() === "true"; 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<string | undefined> => {
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<Props> = ({ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken, setEncryptedToken,
login, login,
setAuthType, setAuthType,
connectionString, connectionString,
setConnectionString, setConnectionString,
setDirectLoginMetadata,
}: Props) => { }: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false); const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState("");
@@ -79,9 +119,39 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
return; return;
} }
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); const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken); setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString); setAuthType(AuthType.ConnectionString);
} catch (error) {
setErrorMessage(
"Failed to connect using the provided connection string. Please verify it is correct and try again.",
);
}
}} }}
> >
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p> <p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
@@ -12,6 +12,8 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); 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", () => { it("should parse a valid mongo account connection string", () => {
@@ -39,6 +41,8 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); 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", () => { it("should parse a valid table account connection string", () => {
@@ -48,6 +52,20 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); 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", () => { it("should parse a valid cassandra account connection string", () => {
@@ -57,6 +75,9 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); 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", () => { it("should fail to parse an invalid connection string", () => {
@@ -1,6 +1,12 @@
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; 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 { export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) { if (connectionString) {
try { try {
@@ -38,6 +44,23 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined; 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; return accessInput;
} catch (error) { } catch (error) {
return undefined; return undefined;
+140 -2
View File
@@ -1,5 +1,11 @@
import { AccessInputMetadata } from "../../Contracts/DataModels"; import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils"; import {
extractAccountKeyFromConnectionString,
extractEndpointHostFromConnectionString,
getDatabaseAccountPropertiesFromMetadata,
isDirectConnectionStringLoginApi,
validateDirectConnectionStringLogin,
} from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => { describe("getDatabaseAccountPropertiesFromMetadata", () => {
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => { 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.",
);
});
});
+120
View File
@@ -45,3 +45,123 @@ export function extractMasterKeyfromConnectionString(connectionString: string):
const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$"); const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$");
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined; 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;
}
+12
View File
@@ -467,13 +467,25 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata), properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: {}, tags: {},
}; };
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({ 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 // For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
updateUserContext({
authType: AuthType.EncryptedToken, authType: AuthType.EncryptedToken,
accessToken: encodeURIComponent(config.encryptedToken), accessToken: encodeURIComponent(config.encryptedToken),
databaseAccount, databaseAccount,
masterKey: config.masterKey, masterKey: config.masterKey,
}); });
}
const explorer = new Explorer(); const explorer = new Explorer();
return explorer; return explorer;
} }