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 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 {
|
||||||
|
|||||||
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user