diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx
index b08a10df3..19483cf7d 100644
--- a/src/HostedExplorer.test.tsx
+++ b/src/HostedExplorer.test.tsx
@@ -159,7 +159,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
});
it("does not open the Data Explorer when the Cosmos client cannot connect", async () => {
- mockValidateDirectConnectionStringConnectivity.mockResolvedValue("Unable to connect to the account.");
+ mockValidateDirectConnectionStringConnectivity.mockRejectedValue(new Error("Unable to connect to the account."));
const { container } = render();
const validConnStr = `AccountEndpoint=https://${FAKE_ACCOUNT_NAME}.documents.azure.com:443/;AccountKey=${FAKE_KEY};`;
diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx
index b4e5a9bcb..982d37d4b 100644
--- a/src/HostedExplorer.tsx
+++ b/src/HostedExplorer.tsx
@@ -23,12 +23,7 @@ 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 {
- extractAccountKeyFromConnectionString,
- extractMasterKeyfromConnectionString,
- isDirectConnectionStringLoginApi,
- validateDirectConnectionStringLogin,
-} from "./Platform/Hosted/HostedUtils";
+import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "./Platform/Hosted/HostedUtils";
import "./Shared/appInsights";
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
import { useAADAuth } from "./hooks/useAADAuth";
@@ -74,31 +69,14 @@ const App: React.FunctionComponent = () => {
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;
- }
+ .then(() => {
setDirectLoginMetadata(metadata);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
logError(
- `Failed to validate connection string for direct login: ${error}`,
+ `Failed to connect with connection string: ${error}`,
"HostedExplorer/connectWithConnectionString",
);
});
@@ -167,9 +145,7 @@ const App: React.FunctionComponent = () => {
authType: AuthType.ConnectionString,
encryptedToken,
encryptedTokenMetadata: accountMetadata,
- masterKey: directLoginMetadata
- ? extractAccountKeyFromConnectionString(connectionString)
- : extractMasterKeyfromConnectionString(connectionString),
+ masterKey: extractMasterKeyfromConnectionString(connectionString),
};
} else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = {
diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json
index b43188c74..9a3c41324 100644
--- a/src/Localization/en/Resources.json
+++ b/src/Localization/en/Resources.json
@@ -1177,15 +1177,8 @@
},
"connectExplorer": {
"errors": {
- "connectionStringMissing": "Connection string is missing.",
- "accountNameMissing": "Account name is missing from the connection string.",
- "endpointHostMissing": "Endpoint host is missing from the connection string.",
- "endpointHostNotAllowed": "Endpoint host is not allowed.",
- "accountNameMismatch": "Account name does not match the endpoint host.",
- "accountKeyMissing": "Account key is missing from the connection string.",
- "connectivityInvalid": "Unable to connect to the account with the provided connection string.",
- "connectivityUnreachable": "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable.",
- "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again."
+ "connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again.",
+ "connectivityUnreachable": "Unable to connect to the account. Please verify the connection string is correct and that the account is reachable."
}
}
}
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx
index ba7958957..a0b65ad58 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx
@@ -11,11 +11,7 @@ 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";
+import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "../HostedUtils";
interface Props {
connectionString: string;
@@ -52,21 +48,17 @@ 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 Cosmos client. Returns undefined on success, or an error message when
-// the client cannot connect.
+// Verifies the connection string can actually authenticate against the account by making a lightweight
+// read of the database account through the Cosmos client. Throws on failure.
export const validateDirectConnectionStringConnectivity = async (
connectionString: string,
metadata: AccessInputMetadata,
-): Promise => {
- const masterKey = extractAccountKeyFromConnectionString(connectionString);
+): Promise => {
+ const masterKey = extractMasterKeyfromConnectionString(connectionString);
if (!metadata?.documentEndpoint || !masterKey) {
- return t(Keys.connectExplorer.errors.connectivityInvalid);
+ throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable));
}
- // The Cosmos client reads its connection settings from userContext, so write the master key and
- // endpoint there. Then we issue a lightweight authenticated read to confirm the
- // credentials work.
updateUserContext({
authType: AuthType.ConnectionString,
masterKey,
@@ -76,9 +68,8 @@ export const validateDirectConnectionStringConnectivity = async (
try {
await client().getDatabaseAccount();
- return undefined;
} catch {
- return t(Keys.connectExplorer.errors.connectivityUnreachable);
+ throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable));
}
};
@@ -125,23 +116,13 @@ export const ConnectExplorer: React.FunctionComponent = ({
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;
+ try {
+ await validateDirectConnectionStringConnectivity(connectionString, metadata);
+ setDirectLoginMetadata(metadata);
+ setAuthType(AuthType.ConnectionString);
+ } catch (error) {
+ setErrorMessage(error.message);
}
- // 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;
}
@@ -150,7 +131,7 @@ export const ConnectExplorer: React.FunctionComponent = ({
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
} catch (error) {
- setErrorMessage(t(Keys.connectExplorer.errors.connectFailed));
+ setErrorMessage(t(Keys.connectExplorer.errors.connectivityUnreachable));
}
}}
>
diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts
index a34b71236..e6f1e4bfd 100644
--- a/src/Platform/Hosted/HostedUtils.test.ts
+++ b/src/Platform/Hosted/HostedUtils.test.ts
@@ -1,10 +1,8 @@
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
import {
- extractAccountKeyFromConnectionString,
- extractEndpointHostFromConnectionString,
+ extractMasterKeyfromConnectionString,
getDatabaseAccountPropertiesFromMetadata,
isDirectConnectionStringLoginApi,
- validateDirectConnectionStringLogin,
} from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => {
@@ -37,13 +35,13 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
});
});
-describe("extractAccountKeyFromConnectionString", () => {
+describe("extractMasterKeyfromConnectionString", () => {
const mockAccountName = "Test";
const mockKey = "abc123+/=someKey==";
it("extracts the account key from a SQL connection string", () => {
expect(
- extractAccountKeyFromConnectionString(
+ extractMasterKeyfromConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
),
).toBe(mockKey);
@@ -51,7 +49,7 @@ describe("extractAccountKeyFromConnectionString", () => {
it("extracts the account key from a Table connection string", () => {
expect(
- extractAccountKeyFromConnectionString(
+ extractMasterKeyfromConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
),
).toBe(mockKey);
@@ -59,7 +57,7 @@ describe("extractAccountKeyFromConnectionString", () => {
it("extracts the account key from a Gremlin connection string", () => {
expect(
- extractAccountKeyFromConnectionString(
+ extractMasterKeyfromConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
),
).toBe(mockKey);
@@ -67,12 +65,12 @@ describe("extractAccountKeyFromConnectionString", () => {
it("returns undefined when there is no account key", () => {
expect(
- extractAccountKeyFromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`),
+ extractMasterKeyfromConnectionString(`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`),
).toBeUndefined();
});
it("returns undefined for an empty connection string", () => {
- expect(extractAccountKeyFromConnectionString("")).toBeUndefined();
+ expect(extractMasterKeyfromConnectionString("")).toBeUndefined();
});
});
@@ -89,82 +87,3 @@ describe("isDirectConnectionStringLoginApi", () => {
expect(isDirectConnectionStringLoginApi(ApiKind.Cassandra)).toBe(false);
});
});
-
-describe("extractEndpointHostFromConnectionString", () => {
- it("extracts the host from a SQL/Gremlin AccountEndpoint", () => {
- expect(
- extractEndpointHostFromConnectionString(
- "AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=key==;",
- ),
- ).toBe("my-account.documents.azure.com");
- });
-
- it("extracts the host from a Table TableEndpoint", () => {
- expect(
- extractEndpointHostFromConnectionString(
- "DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=key==;TableEndpoint=https://my-account.table.cosmos.azure.com:443/;",
- ),
- ).toBe("my-account.table.cosmos.azure.com");
- });
-
- it("returns undefined when no endpoint host is present", () => {
- expect(extractEndpointHostFromConnectionString("AccountName=my-account;AccountKey=key==;")).toBeUndefined();
- });
-});
-
-describe("validateDirectConnectionStringLogin", () => {
- const mockKey = "abc123+/=someKey==";
- const sqlMetadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata;
- const tableMetadata = { accountName: "my-account", apiKind: ApiKind.Table } as AccessInputMetadata;
- const gremlinMetadata = { accountName: "my-account", apiKind: ApiKind.Graph } as AccessInputMetadata;
-
- it("returns undefined for a valid SQL connection string", () => {
- const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`;
- expect(validateDirectConnectionStringLogin(connectionString, sqlMetadata)).toBeUndefined();
- });
-
- it("returns undefined for a valid Table connection string using the cosmos.azure.com zone", () => {
- const connectionString = `DefaultEndpointsProtocol=https;AccountName=my-account;AccountKey=${mockKey};TableEndpoint=https://my-account.table.cosmos.azure.com:443/;`;
- expect(validateDirectConnectionStringLogin(connectionString, tableMetadata)).toBeUndefined();
- });
-
- it("returns undefined for a valid Gremlin connection string", () => {
- const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`;
- expect(validateDirectConnectionStringLogin(connectionString, gremlinMetadata)).toBeUndefined();
- });
-
- it("rejects an empty connection string", () => {
- expect(validateDirectConnectionStringLogin("", {} as AccessInputMetadata)).toBe("Connection string is missing.");
- });
-
- it("rejects when the account name is missing from metadata", () => {
- const connectionString = `AccountEndpoint=https://my-account.documents.azure.com:443/;AccountKey=${mockKey};`;
- expect(validateDirectConnectionStringLogin(connectionString, {} as AccessInputMetadata)).toBe(
- "Account name is missing from the connection string.",
- );
- });
-
- it("rejects a host that is not in an allowlisted zone", () => {
- // Point the endpoint host at an untrusted zone while keeping a valid parsed account name.
- const tamperedConnectionString = `AccountEndpoint=https://my-account.evil.example.com:443/;AccountKey=${mockKey};`;
- expect(validateDirectConnectionStringLogin(tamperedConnectionString, sqlMetadata)).toBe(
- "Endpoint host is not allowed.",
- );
- });
-
- it("rejects when the account name does not match the endpoint host label", () => {
- const connectionString = `AccountEndpoint=https://real-account.documents.azure.com:443/;AccountKey=${mockKey};`;
- const metadata = { accountName: "different-account", apiKind: ApiKind.SQL } as AccessInputMetadata;
- expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe(
- "Account name does not match the endpoint host.",
- );
- });
-
- it("rejects when the account key is missing", () => {
- const connectionString = "AccountEndpoint=https://my-account.documents.azure.com:443/;";
- const metadata = { accountName: "my-account", apiKind: ApiKind.SQL } as AccessInputMetadata;
- expect(validateDirectConnectionStringLogin(connectionString, metadata)).toBe(
- "Account key is missing from the connection string.",
- );
- });
-});
diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts
index fc9e21ecb..e4f35e99a 100644
--- a/src/Platform/Hosted/HostedUtils.ts
+++ b/src/Platform/Hosted/HostedUtils.ts
@@ -1,6 +1,5 @@
import { AccountKind, CapabilityNames } from "../../Common/Constants";
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
-import { Keys, t } from "../../Localization";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
import { userContext } from "../../UserContext";
@@ -41,15 +40,9 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC
return AccountKind.GlobalDocumentDB;
}
-export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined {
- // Only Gremlin uses the actual master key for connection to cosmos
- 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 {
+export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined {
const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
}
@@ -59,101 +52,3 @@ export function extractAccountKeyFromConnectionString(connectionString: string):
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) and HostName.
-export function extractEndpointHostFromConnectionString(connectionString: string): string | undefined {
- for (const part of connectionString.split(";")) {
- const trimmed = part.trim();
-
- 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 t(Keys.connectExplorer.errors.connectionStringMissing);
- }
-
- if (!metadata || !metadata.accountName) {
- return t(Keys.connectExplorer.errors.accountNameMissing);
- }
-
- const host = extractEndpointHostFromConnectionString(connectionString);
- if (!host) {
- return t(Keys.connectExplorer.errors.endpointHostMissing);
- }
-
- // 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 t(Keys.connectExplorer.errors.endpointHostNotAllowed);
- }
-
- // The account name must be the first DNS label of the host.
- if (host.split(".")[0].toLowerCase() !== metadata.accountName.toLowerCase()) {
- return t(Keys.connectExplorer.errors.accountNameMismatch);
- }
-
- // Direct login signs requests with the account key, so it must be present in the connection string.
- if (!extractAccountKeyFromConnectionString(connectionString)) {
- return t(Keys.connectExplorer.errors.accountKeyMissing);
- }
-
- return undefined;
-}
diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts
index 6c6c1c368..7e46ae500 100644
--- a/src/hooks/useKnockoutExplorer.ts
+++ b/src/hooks/useKnockoutExplorer.ts
@@ -478,7 +478,6 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
});
} 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
updateUserContext({
authType: AuthType.EncryptedToken,
accessToken: encodeURIComponent(config.encryptedToken),