mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-08-30 10:18:40 +01:00
Add client-side connection-string login for SQL, Tables, and Gremlin (#2559)
* 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. * Localize connection-string login validation and connectivity messages Move the hardcoded SQL/Tables/Gremlin connection-string login strings into en/Resources.json and reference them via the type-safe Keys object (t(Keys.connectExplorer.errors.*)). * Drop unused mongodb:// branch from endpoint host extraction * Remove Data Explorer references from connectivity probe comment * Reword proxy reference in ConnectionString comment * Fix trailing whitespace in connectivity probe comment * Send connection string in Authorization header for Mongo/Cassandra token request * Add E2E connection string login tests for SQL, Gremlin, and Tables * Add wrong account key test for SQL connection string login * Add access token to authorization header for encrypted token flow * Wire connection string login E2E tests to dedicated connstring accounts in CI Add TestAuthType and fold connection-string account resolution into getAccountName; seed and target the dedicated *-connstring accounts in CI while falling back to the standard per-API account locally. * Handle connection string account types in getTestExplorerUrl switch * Move globalThis.crypto polyfill to fx.ts so it runs for all specs * Remove connection string validation for connection string login - Remove validateDirectConnectionStringLogin and its helpers (extractEndpointHostFromConnectionString, extractHostToken, directLoginAllowlistedEndpointZones) - Remove old extractMasterKeyfromConnectionString (Gremlin-specific), rename extractAccountKeyFromConnectionString to extractMasterKeyfromConnectionString - Change validateDirectConnectionStringConnectivity to throw on error instead of returning string|undefined - Simplify direct-login flow: submit connection string to CosmosClient as-is, no format or endpoint validation - Keep connectivity pre-check (throws if CosmosClient cannot reach account) - Remove 6 unused localization keys for validation errors - Update tests to match new behavior * Send authorization header for connection string login backend calls * Simplify connection string login error handling - Lift the login error state into HostedExplorer so failures from the connect form and from a postMessage login share one source of truth - Show the message returned by the service instead of falling back to a generic unreachable message - Move isAuthorizationError into AuthorizationUtils alongside the other shared auth helpers - Widen getErrorMessage to accept unknown so catch variables no longer need a cast - Show the connect screen error tooltip beside the icon and widen it so long service messages fit - Consolidate the repeated account lookup and login steps in the SQL connection string spec * Consolidate hosted login account metadata into a single state Encrypted-token and direct connection-string logins now write to one accountMetadata state instead of two, which also fixes the connect form staying mounted after a successful SQL/Tables/Gremlin login. Deletes the now-unused useTokenMetadata hook and moves fetchAccessData to Platform/Hosted/Helpers/PortalAccessData.ts. * Accept connection string logins without validating them SQL, Tables, and Gremlin logins probed the account before signing in, so a user whose key was wrong or whose account was unreachable was blocked at the connect form. The probe is now gone: the connection string is accepted as-is and any bad key surfaces on the data-plane requests made from inside the explorer. Removes validateDirectConnectionStringConnectivity, isAuthorizationError, and the connectExplorer.errors.connectFailed string along with their tests. Also takes documentEndpoint straight from the AccountEndpoint in SQL and Gremlin connection strings instead of rebuilding it from the account name and a hardcoded DNS zone. Tables still derives it, since a Tables connection string only carries the table endpoint. * Keep the connect form login error local to ConnectExplorer The error state was lifted into HostedExplorer so a postMessage login could report failures, which meant the connect screen needed a second copy of the error markup for the AAD view. That copy fell outside the connectExplorerContent selector the tooltip styles are scoped to, so it rendered the message as unstyled inline text. Move the state back into ConnectExplorer and let connectWithConnectionString log the failure as it did before. A failed postMessage login still leaves the user on the connect screen either way. * Move Portal Backend calls into a PortalBackendClient helper fetchEncryptedToken and isAccountRestrictedForConnectionStringLogin lived in ConnectExplorer.tsx, so HostedExplorer imported a network call from a React component. Merge them with fetchAccessData into src/Platform/Hosted/Helpers/PortalBackendClient.ts along with the PortalBackendError type, leaving ConnectExplorer as UI only. * Leave the encrypted token login path as it is on master The direct connection string login work does not change how Mongo and Cassandra logins fail, so drop the 401/403 handling this branch added around fetchEncryptedToken along with the now unused isAuthorizationError helper. * Move the Portal Backend connection string calls out of Helpers Helpers holds pure string utilities, and every other *Client in the repo sits at its domain root, so name the module for the endpoints it wraps and place it beside HostedUtils. Also drop the branch's ConnectScreen.less tooltip restyle, which was unrelated to connection string login. * Move the Portal Backend client to Common The module is a plain service client with no dependency on the hosted platform, and CosmosClient and MetricEvents already hand roll their own Portal Backend calls, so Common is where a shared client belongs. * Add an E2E test that SQL connection string login skips the Portal Backend Nothing asserted the defining behavior of the direct login path, so reverting the short-circuit in connectWithConnectionString would have gone unnoticed. The listener filters on the connectionstring route so it covers both generatetoken and accessinputmetadata, and leaves the account restriction check alone since that still runs for every API. * Refer to the Table API as Table in comments The comments added by this branch alternated between Table and Tables when naming the API alongside SQL and Gremlin. TablesDB is left alone since that is the literal database name, as is the plural noun where it refers to actual tables. --------- Co-authored-by: Asier Isayas <aisayas@microsoft.com>
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 {
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function getTokenFromAuthService(
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ms-encrypted-auth-token": userContext.accessToken,
|
||||
authorization: userContext.accessToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verb,
|
||||
|
||||
@@ -22,7 +22,10 @@ const defaultHeaders = {
|
||||
|
||||
function authHeaders() {
|
||||
if (userContext.authType === AuthType.EncryptedToken) {
|
||||
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
|
||||
return {
|
||||
[HttpHeaders.guestAccessToken]: userContext.accessToken,
|
||||
[HttpHeaders.authorization]: userContext.accessToken,
|
||||
};
|
||||
} else {
|
||||
const headers: { [key: string]: string } = {
|
||||
[HttpHeaders.authorization]: userContext.authorizationToken,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { configContext } from "../ConfigContext";
|
||||
import { AccessInputMetadata } from "../Contracts/DataModels";
|
||||
import { HttpHeaders } from "./Constants";
|
||||
|
||||
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
|
||||
const headers = new Headers();
|
||||
// Portal encrypted token API quirk: The token header must be URL encoded
|
||||
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
|
||||
headers.append(HttpHeaders.authorization, encodeURIComponent(portalToken));
|
||||
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then((response) => response.json())
|
||||
.catch((error) => console.error(error));
|
||||
}
|
||||
|
||||
export async function fetchEncryptedToken(connectionString: string): Promise<string> {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
headers.append(HttpHeaders.authorization, connectionString);
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
const encryptedTokenResponse: string = await response.json();
|
||||
return decodeURIComponent(encryptedTokenResponse);
|
||||
}
|
||||
|
||||
export async function isAccountRestrictedForConnectionStringLogin(connectionString: string): Promise<boolean> {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return (await response.text()).toLowerCase() === "true";
|
||||
}
|
||||
@@ -552,6 +552,10 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
|
||||
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
|
||||
|
||||
if (userContext.authType === AuthType.EncryptedToken) {
|
||||
xhr.setRequestHeader(Constants.HttpHeaders.authorization, userContext.accessToken);
|
||||
}
|
||||
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
jest.mock("./hooks/useAADAuth");
|
||||
jest.mock("./hooks/useConfig");
|
||||
jest.mock("./hooks/usePortalAccessToken");
|
||||
jest.mock("./Common/PortalBackendClient");
|
||||
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
|
||||
jest.mock("./Shared/appInsights");
|
||||
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
|
||||
@@ -25,11 +25,11 @@ jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
|
||||
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 } from "./Platform/Hosted/Components/ConnectExplorer";
|
||||
|
||||
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
|
||||
|
||||
@@ -54,7 +54,7 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth);
|
||||
(useConfig as jest.Mock).mockReturnValue({});
|
||||
(useTokenMetadata as jest.Mock).mockReturnValue(undefined);
|
||||
(fetchAccessData as jest.Mock).mockResolvedValue(undefined);
|
||||
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr);
|
||||
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid Mongo connection string from an allowed origin", async () => {
|
||||
@@ -129,7 +129,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr);
|
||||
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
|
||||
@@ -145,7 +145,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr);
|
||||
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects messages from a disallowed origin", async () => {
|
||||
|
||||
+58
-29
@@ -1,5 +1,6 @@
|
||||
import { initializeIcons } from "@fluentui/react";
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
@@ -7,11 +8,12 @@ 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 { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
|
||||
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 } 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,12 +21,14 @@ 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 {
|
||||
extractMasterKeyFromDirectLoginConnectionString,
|
||||
isDirectConnectionStringLoginApi,
|
||||
} from "./Platform/Hosted/HostedUtils";
|
||||
import "./Shared/appInsights";
|
||||
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
|
||||
import { useAADAuth } from "./hooks/useAADAuth";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
||||
|
||||
initializeIcons();
|
||||
|
||||
@@ -32,7 +36,15 @@ const App: React.FunctionComponent = () => {
|
||||
// For handling encrypted portal tokens sent via query paramter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [encryptedToken, setEncryptedToken] = React.useState<string>(params && params.get("key"));
|
||||
const encryptedTokenMetadata = useTokenMetadata(encryptedToken);
|
||||
// Encrypted token logins resolve the account metadata through the Portal Backend, while SQL/Table/Gremlin
|
||||
// connection-string logins derive it client-side, so both paths write to the same value.
|
||||
const [accountMetadata, setAccountMetadata] = React.useState<AccessInputMetadata>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (encryptedToken) {
|
||||
fetchAccessData(encryptedToken).then(setAccountMetadata);
|
||||
}
|
||||
}, [encryptedToken]);
|
||||
|
||||
// For showing/hiding panel
|
||||
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
|
||||
@@ -54,19 +66,29 @@ const App: React.FunctionComponent = () => {
|
||||
setConnectionString(connStr);
|
||||
if (isResourceTokenConnectionString(connStr)) {
|
||||
setAuthType(AuthType.ResourceToken);
|
||||
} else {
|
||||
fetchEncryptedToken(connStr)
|
||||
.then((token) => {
|
||||
setEncryptedToken(token);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(
|
||||
`Failed to connect with connection string: ${error}`,
|
||||
"HostedExplorer/connectWithConnectionString",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = parseConnectionString(connStr);
|
||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||
// SQL, Table, 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.
|
||||
setAccountMetadata(metadata);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchEncryptedToken(connStr)
|
||||
.then((token) => {
|
||||
setEncryptedToken(token);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(
|
||||
`Failed to connect with connection string: ${getErrorMessage(error)}`,
|
||||
"HostedExplorer/connectWithConnectionString",
|
||||
);
|
||||
});
|
||||
},
|
||||
[authType],
|
||||
);
|
||||
@@ -114,14 +136,14 @@ const App: React.FunctionComponent = () => {
|
||||
frameWindow.hostedConfig = {
|
||||
authType: AuthType.EncryptedToken,
|
||||
encryptedToken,
|
||||
encryptedTokenMetadata,
|
||||
encryptedTokenMetadata: accountMetadata,
|
||||
};
|
||||
} else if (authType === AuthType.ConnectionString) {
|
||||
frameWindow.hostedConfig = {
|
||||
authType: AuthType.ConnectionString,
|
||||
encryptedToken,
|
||||
encryptedTokenMetadata,
|
||||
masterKey: extractMasterKeyfromConnectionString(connectionString),
|
||||
encryptedTokenMetadata: accountMetadata,
|
||||
masterKey: extractMasterKeyFromDirectLoginConnectionString(connectionString),
|
||||
};
|
||||
} else if (authType === AuthType.ResourceToken) {
|
||||
frameWindow.hostedConfig = {
|
||||
@@ -140,7 +162,7 @@ const App: React.FunctionComponent = () => {
|
||||
|
||||
const showExplorer =
|
||||
(config && isLoggedIn && databaseAccount && !connectionString) ||
|
||||
(encryptedTokenMetadata && encryptedTokenMetadata) ||
|
||||
accountMetadata ||
|
||||
(authType === AuthType.ResourceToken && connectionString);
|
||||
|
||||
return (
|
||||
@@ -157,7 +179,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 +187,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>
|
||||
@@ -188,9 +210,7 @@ const App: React.FunctionComponent = () => {
|
||||
// It's possible this can be changed once all knockout code has been removed.
|
||||
<iframe
|
||||
// Setting key is needed so React will re-render this element on any account change
|
||||
key={
|
||||
authType ? `${authType}-${encryptedTokenMetadata?.accountName || connectionString}` : databaseAccount?.id
|
||||
}
|
||||
key={authType ? `${authType}-${accountMetadata?.accountName || connectionString}` : databaseAccount?.id}
|
||||
ref={ref}
|
||||
data-test="DataExplorerFrame"
|
||||
id="explorerMenu"
|
||||
@@ -200,8 +220,17 @@ const App: React.FunctionComponent = () => {
|
||||
src="explorer.html?v=1.0.1&platform=Hosted"
|
||||
></iframe>
|
||||
)}
|
||||
{!isLoggedIn && !encryptedTokenMetadata && (
|
||||
<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />
|
||||
{!isLoggedIn && !accountMetadata && (
|
||||
<ConnectExplorer
|
||||
{...{
|
||||
login,
|
||||
setEncryptedToken,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setAccountMetadata,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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, Table, and Gremlin sign data-plane requests client-side with the master key and do not need the
|
||||
// proxies, so they carry no encrypted token. Mongo and Cassandra still use the encrypted
|
||||
// token because their operations go through the proxies.
|
||||
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, Table, Gremlin). Mongo/Cassandra leave it undefined.
|
||||
masterKey?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1175,4 +1175,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,20 @@ it("shows the connect form", () => {
|
||||
const setConnectionString = jest.fn();
|
||||
const setEncryptedToken = jest.fn();
|
||||
const setAuthType = jest.fn();
|
||||
const setAccountMetadata = jest.fn();
|
||||
|
||||
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
|
||||
render(
|
||||
<ConnectExplorer
|
||||
{...{
|
||||
login,
|
||||
setEncryptedToken,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setAccountMetadata,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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 +37,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
|
||||
const setConnectionString = jest.fn();
|
||||
const setEncryptedToken = jest.fn();
|
||||
const setAuthType = jest.fn();
|
||||
const setAccountMetadata = jest.fn();
|
||||
const oldFeatures = userContext.features;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
@@ -34,7 +47,18 @@ 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,
|
||||
setAccountMetadata,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
|
||||
|
||||
updateUserContext({ features: oldFeatures });
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { 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 { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
||||
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
|
||||
import { isDirectConnectionStringLoginApi } from "../HostedUtils";
|
||||
|
||||
interface Props {
|
||||
connectionString: string;
|
||||
@@ -14,39 +17,16 @@ interface Props {
|
||||
setEncryptedToken: (token: string) => void;
|
||||
setConnectionString: (connectionString: string) => void;
|
||||
setAuthType: (authType: AuthType) => void;
|
||||
setAccountMetadata: (metadata: AccessInputMetadata) => void;
|
||||
}
|
||||
|
||||
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
const encryptedTokenResponse: string = await response.json();
|
||||
return decodeURIComponent(encryptedTokenResponse);
|
||||
};
|
||||
|
||||
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return (await response.text()).toLowerCase() === "true";
|
||||
};
|
||||
|
||||
export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setEncryptedToken,
|
||||
login,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setAccountMetadata,
|
||||
}: Props) => {
|
||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
@@ -67,10 +47,15 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||
setErrorMessage(
|
||||
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
|
||||
);
|
||||
try {
|
||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||
setErrorMessage(
|
||||
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,6 +64,15 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = parseConnectionString(connectionString);
|
||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||
// SQL, Table, 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.
|
||||
setAccountMetadata(metadata);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedToken = await fetchEncryptedToken(connectionString);
|
||||
setEncryptedToken(encryptedToken);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
|
||||
@@ -12,6 +12,18 @@ 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 keep the document endpoint given by the connection string", () => {
|
||||
// The endpoint is taken from the connection string rather than rebuilt from the account name, so a
|
||||
// string that omits the port keeps it omitted.
|
||||
const metadata = parseConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com/;AccountKey=${mockMasterKey};`,
|
||||
);
|
||||
|
||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
|
||||
});
|
||||
|
||||
it("should parse a valid mongo account connection string", () => {
|
||||
@@ -39,6 +51,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 +62,20 @@ describe("ConnectionStringParser", () => {
|
||||
|
||||
expect(metadata.accountName).toBe(mockAccountName);
|
||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
||||
// Table 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", () => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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 when the connection string does not already
|
||||
// contain the endpoint.
|
||||
const DocumentEndpointZone = "documents.azure.com";
|
||||
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
|
||||
const DnsPort = "443";
|
||||
|
||||
export function parseConnectionString(connectionString: string): AccessInputMetadata {
|
||||
if (connectionString) {
|
||||
try {
|
||||
@@ -11,6 +18,9 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
|
||||
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
|
||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
|
||||
accessInput.apiKind = ApiKind.SQL;
|
||||
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
|
||||
// given instead of rebuilding it from the account name.
|
||||
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
|
||||
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
|
||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
|
||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||
@@ -38,6 +48,17 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
|
||||
// operations go through has to be derived from the account name. Gremlin accounts additionally
|
||||
// need the Gremlin endpoint, which is never part of the connection string.
|
||||
if (accessInput.accountName) {
|
||||
if (accessInput.apiKind === ApiKind.Table) {
|
||||
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
|
||||
} else if (accessInput.apiKind === ApiKind.Graph) {
|
||||
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
return accessInput;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { AccessInputMetadata } from "../../Contracts/DataModels";
|
||||
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
|
||||
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
|
||||
import {
|
||||
extractMasterKeyFromDirectLoginConnectionString,
|
||||
getDatabaseAccountPropertiesFromMetadata,
|
||||
isDirectConnectionStringLoginApi,
|
||||
} from "./HostedUtils";
|
||||
|
||||
describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
||||
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
|
||||
@@ -30,3 +34,58 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMasterKeyFromDirectLoginConnectionString", () => {
|
||||
const mockAccountName = "Test";
|
||||
const mockKey = "abc123+/=someKey==";
|
||||
|
||||
it("extracts the account key from a SQL connection string", () => {
|
||||
expect(
|
||||
extractMasterKeyFromDirectLoginConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
|
||||
),
|
||||
).toBe(mockKey);
|
||||
});
|
||||
|
||||
it("extracts the account key from a Table connection string", () => {
|
||||
expect(
|
||||
extractMasterKeyFromDirectLoginConnectionString(
|
||||
`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(
|
||||
extractMasterKeyFromDirectLoginConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
|
||||
),
|
||||
).toBe(mockKey);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no account key", () => {
|
||||
expect(
|
||||
extractMasterKeyFromDirectLoginConnectionString(
|
||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty connection string", () => {
|
||||
expect(extractMasterKeyFromDirectLoginConnectionString("")).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,14 @@ 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;$");
|
||||
// Returns the master key carried by SQL, Table, and Gremlin connection strings.
|
||||
export function extractMasterKeyFromDirectLoginConnectionString(connectionString: string): string | undefined {
|
||||
const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
|
||||
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
|
||||
}
|
||||
|
||||
// SQL, Table, 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.
|
||||
export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean {
|
||||
return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph;
|
||||
}
|
||||
|
||||
@@ -467,13 +467,24 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
|
||||
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
|
||||
tags: {},
|
||||
};
|
||||
updateUserContext({
|
||||
// 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,
|
||||
});
|
||||
if (config.masterKey && !config.encryptedToken) {
|
||||
// Direct client-side signing path (SQL, Table, 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).
|
||||
updateUserContext({
|
||||
authType: AuthType.EncryptedToken,
|
||||
accessToken: encodeURIComponent(config.encryptedToken),
|
||||
databaseAccount,
|
||||
masterKey: config.masterKey,
|
||||
});
|
||||
}
|
||||
const explorer = new Explorer();
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HttpHeaders } from "../Common/Constants";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import { AccessInputMetadata } from "../Contracts/DataModels";
|
||||
|
||||
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
|
||||
const headers = new Headers();
|
||||
// Portal encrypted token API quirk: The token header must be URL encoded
|
||||
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
|
||||
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
return fetch(url, options)
|
||||
.then((response) => response.json())
|
||||
.catch((error) => console.error(error));
|
||||
}
|
||||
|
||||
export function useTokenMetadata(token: string): AccessInputMetadata | undefined {
|
||||
const [state, setState] = useState<AccessInputMetadata | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchAccessData(token).then((response) => setState(response));
|
||||
}
|
||||
}, [token]);
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user