mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-19 17:12:47 +01:00
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:
@@ -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 {
|
||||
|
||||
@@ -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<typeof fetchEncryptedToken>;
|
||||
const mockValidateDirectConnectionStringConnectivity =
|
||||
validateDirectConnectionStringConnectivity as jest.MockedFunction<typeof validateDirectConnectionStringConnectivity>;
|
||||
|
||||
(ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />);
|
||||
|
||||
@@ -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 = "<redacted-test-key>";
|
||||
|
||||
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 />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<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 () => {
|
||||
|
||||
+65
-15
@@ -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<DatabaseAccount>();
|
||||
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
|
||||
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>();
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
<span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span>
|
||||
{(isLoggedIn || encryptedTokenMetadata?.accountName) && (
|
||||
{(isLoggedIn || accountMetadata?.accountName) && (
|
||||
<img className="chevronRight" src={ChevronRight} alt="account separator" />
|
||||
)}
|
||||
{isLoggedIn && !connectionString && (
|
||||
@@ -165,9 +213,9 @@ const App: React.FunctionComponent = () => {
|
||||
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
|
||||
</span>
|
||||
)}
|
||||
{(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && (
|
||||
{(!isLoggedIn || connectionString) && accountMetadata?.accountName && (
|
||||
<span className="accountSwitchComponentContainer">
|
||||
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
|
||||
<span className="accountNameHeader">{accountMetadata?.accountName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,7 +249,9 @@ const App: React.FunctionComponent = () => {
|
||||
></iframe>
|
||||
)}
|
||||
{!isLoggedIn && !encryptedTokenMetadata && (
|
||||
<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />
|
||||
<ConnectExplorer
|
||||
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
|
||||
/>
|
||||
)}
|
||||
{isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />}
|
||||
{isLoggedIn && !authFailure && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
|
||||
render(
|
||||
<ConnectExplorer
|
||||
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
|
||||
/>,
|
||||
);
|
||||
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(<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();
|
||||
|
||||
updateUserContext({ features: oldFeatures });
|
||||
|
||||
@@ -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<string> => {
|
||||
@@ -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<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> = ({
|
||||
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<Props> = ({
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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.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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user