mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-20 01:22:47 +01:00
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
This commit is contained in:
@@ -42,8 +42,8 @@ export const handleError = (
|
||||
}
|
||||
};
|
||||
|
||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||
let errorMessage = typeof error === "string" ? error : error.message;
|
||||
export const getErrorMessage = (error: unknown = ""): string => {
|
||||
let errorMessage = typeof error === "string" ? error : (error as Error)?.message;
|
||||
if (!errorMessage) {
|
||||
errorMessage = stringifyError(error);
|
||||
}
|
||||
|
||||
+22
-3
@@ -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";
|
||||
@@ -47,6 +48,7 @@ const App: React.FunctionComponent = () => {
|
||||
const [databaseAccount, setDatabaseAccount] = React.useState<DatabaseAccount>();
|
||||
const [authType, setAuthType] = React.useState<AuthType>(encryptedToken ? AuthType.EncryptedToken : undefined);
|
||||
const [connectionString, setConnectionString] = React.useState<string>();
|
||||
const [errorMessage, setErrorMessage] = 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>();
|
||||
@@ -59,6 +61,7 @@ const App: React.FunctionComponent = () => {
|
||||
if (!connStr || authType) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(undefined);
|
||||
setConnectionString(connStr);
|
||||
if (isResourceTokenConnectionString(connStr)) {
|
||||
setAuthType(AuthType.ResourceToken);
|
||||
@@ -75,10 +78,12 @@ const App: React.FunctionComponent = () => {
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = getErrorMessage(error);
|
||||
logError(
|
||||
`Failed to connect with connection string: ${error}`,
|
||||
`Failed to connect with connection string: ${message}`,
|
||||
"HostedExplorer/connectWithConnectionString",
|
||||
);
|
||||
setErrorMessage(message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -89,7 +94,12 @@ const App: React.FunctionComponent = () => {
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(`Failed to connect with connection string: ${error}`, "HostedExplorer/connectWithConnectionString");
|
||||
const message = getErrorMessage(error);
|
||||
logError(
|
||||
`Failed to connect with connection string: ${message}`,
|
||||
"HostedExplorer/connectWithConnectionString",
|
||||
);
|
||||
setErrorMessage(message);
|
||||
});
|
||||
},
|
||||
[authType],
|
||||
@@ -226,7 +236,16 @@ const App: React.FunctionComponent = () => {
|
||||
)}
|
||||
{!isLoggedIn && !encryptedTokenMetadata && (
|
||||
<ConnectExplorer
|
||||
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
|
||||
{...{
|
||||
login,
|
||||
setEncryptedToken,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setDirectLoginMetadata,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />}
|
||||
|
||||
@@ -1177,8 +1177,7 @@
|
||||
},
|
||||
"connectExplorer": {
|
||||
"errors": {
|
||||
"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."
|
||||
"connectFailed": "Failed to connect using the provided connection string. Please verify it is correct and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
jest.mock("../../../hooks/useDirectories");
|
||||
jest.mock("Common/CosmosClient", () => ({
|
||||
client: jest.fn(),
|
||||
}));
|
||||
jest.mock("Common/Logger", () => ({
|
||||
logWarning: jest.fn(),
|
||||
}));
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { client } from "Common/CosmosClient";
|
||||
import { extractFeatures } from "Platform/Hosted/extractFeatures";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import React from "react";
|
||||
import { ConnectExplorer } from "./ConnectExplorer";
|
||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||
import { ConnectExplorer, validateDirectConnectionStringConnectivity } from "./ConnectExplorer";
|
||||
|
||||
it("shows the connect form", () => {
|
||||
const connectionString = "fakeConnectionString";
|
||||
@@ -13,10 +21,19 @@ it("shows the connect form", () => {
|
||||
const setEncryptedToken = jest.fn();
|
||||
const setAuthType = jest.fn();
|
||||
const setDirectLoginMetadata = jest.fn();
|
||||
const setErrorMessage = jest.fn();
|
||||
|
||||
render(
|
||||
<ConnectExplorer
|
||||
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
|
||||
{...{
|
||||
login,
|
||||
setEncryptedToken,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setDirectLoginMetadata,
|
||||
setErrorMessage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull();
|
||||
@@ -31,6 +48,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
|
||||
const setEncryptedToken = jest.fn();
|
||||
const setAuthType = jest.fn();
|
||||
const setDirectLoginMetadata = jest.fn();
|
||||
const setErrorMessage = jest.fn();
|
||||
const oldFeatures = userContext.features;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
@@ -42,10 +60,57 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
|
||||
|
||||
render(
|
||||
<ConnectExplorer
|
||||
{...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString, setDirectLoginMetadata }}
|
||||
{...{
|
||||
login,
|
||||
setEncryptedToken,
|
||||
setAuthType,
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setDirectLoginMetadata,
|
||||
setErrorMessage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
|
||||
|
||||
updateUserContext({ features: oldFeatures });
|
||||
});
|
||||
|
||||
describe("validateDirectConnectionStringConnectivity", () => {
|
||||
const connectionString = "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=fakeKey==;";
|
||||
const metadata = { documentEndpoint: "https://test.documents.azure.com:443/" } as AccessInputMetadata;
|
||||
|
||||
const mockGetDatabaseAccount = (getDatabaseAccount: () => Promise<unknown>) => {
|
||||
(client as jest.Mock).mockReturnValue({ getDatabaseAccount });
|
||||
};
|
||||
|
||||
it.each([401, 403])("blocks the login and surfaces the error returned for a %i", async (statusCode) => {
|
||||
// The Cosmos SDK reports the HTTP status on `ErrorResponse.code`.
|
||||
const authorizationError = Object.assign(new Error("The input authorization token is invalid"), {
|
||||
code: statusCode,
|
||||
});
|
||||
mockGetDatabaseAccount(() => Promise.reject(authorizationError));
|
||||
|
||||
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).rejects.toBe(
|
||||
authorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([429, 503])("does not block the login for a %i", async (statusCode) => {
|
||||
mockGetDatabaseAccount(() => Promise.reject(Object.assign(new Error("Service unavailable"), { code: statusCode })));
|
||||
|
||||
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not block the login when the account is unreachable", async () => {
|
||||
mockGetDatabaseAccount(() => Promise.reject(new Error("Failed to fetch")));
|
||||
|
||||
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows the login when the account can be read", async () => {
|
||||
mockGetDatabaseAccount(() => Promise.resolve({}));
|
||||
|
||||
await expect(validateDirectConnectionStringConnectivity(connectionString, metadata)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { client } from "Common/CosmosClient";
|
||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { logWarning } from "Common/Logger";
|
||||
import { Keys, t } from "Localization";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import * as React from "react";
|
||||
@@ -9,6 +11,7 @@ import { AuthType } from "../../../AuthType";
|
||||
import { HttpHeaders } from "../../../Common/Constants";
|
||||
import { configContext } from "../../../ConfigContext";
|
||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||
import { isAuthorizationError } from "../../../Utils/AuthorizationUtils";
|
||||
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
||||
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
|
||||
import { extractMasterKeyfromConnectionString, isDirectConnectionStringLoginApi } from "../HostedUtils";
|
||||
@@ -20,8 +23,15 @@ interface Props {
|
||||
setConnectionString: (connectionString: string) => void;
|
||||
setAuthType: (authType: AuthType) => void;
|
||||
setDirectLoginMetadata: (metadata: AccessInputMetadata) => void;
|
||||
errorMessage?: string;
|
||||
setErrorMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
// Turns a failed Portal Backend response into an error that carries the message returned by the service
|
||||
// and the HTTP status code, so the caller can tell an authorization failure from anything else.
|
||||
const errorFromResponse = async (response: Response): Promise<Error> =>
|
||||
Object.assign(new Error((await response.text()) || response.statusText), { statusCode: response.status });
|
||||
|
||||
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
|
||||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
@@ -29,7 +39,7 @@ export const fetchEncryptedToken = async (connectionString: string): Promise<str
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
throw await errorFromResponse(response);
|
||||
}
|
||||
|
||||
const encryptedTokenResponse: string = await response.json();
|
||||
@@ -42,21 +52,24 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
|
||||
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
|
||||
const response = await fetch(url, { headers, method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
throw await errorFromResponse(response);
|
||||
}
|
||||
|
||||
return (await response.text()).toLowerCase() === "true";
|
||||
};
|
||||
|
||||
// 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.
|
||||
// read of the database account through the Cosmos client. A 401 or 403 means the key is invalid or not
|
||||
// authorized, so the login is blocked and the error returned by the service is shown. Any other failure
|
||||
// (network blip, throttling, service outage) is not a credential problem, so the login is allowed to
|
||||
// continue instead of blocking on a transient error.
|
||||
export const validateDirectConnectionStringConnectivity = async (
|
||||
connectionString: string,
|
||||
metadata: AccessInputMetadata,
|
||||
): Promise<void> => {
|
||||
const masterKey = extractMasterKeyfromConnectionString(connectionString);
|
||||
if (!metadata?.documentEndpoint || !masterKey) {
|
||||
throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable));
|
||||
throw new Error(t(Keys.connectExplorer.errors.connectFailed));
|
||||
}
|
||||
|
||||
updateUserContext({
|
||||
@@ -68,8 +81,15 @@ export const validateDirectConnectionStringConnectivity = async (
|
||||
|
||||
try {
|
||||
await client().getDatabaseAccount();
|
||||
} catch {
|
||||
throw new Error(t(Keys.connectExplorer.errors.connectivityUnreachable));
|
||||
} catch (error) {
|
||||
if (isAuthorizationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logWarning(
|
||||
`Could not verify the connection string against the account: ${getErrorMessage(error)}`,
|
||||
"ConnectExplorer/validateDirectConnectionStringConnectivity",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,9 +100,10 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
connectionString,
|
||||
setConnectionString,
|
||||
setDirectLoginMetadata,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
}: Props) => {
|
||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState("");
|
||||
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
||||
|
||||
return (
|
||||
@@ -100,10 +121,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;
|
||||
}
|
||||
|
||||
@@ -121,7 +147,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setDirectLoginMetadata(metadata);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message);
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -131,7 +157,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setEncryptedToken(encryptedToken);
|
||||
setAuthType(AuthType.ConnectionString);
|
||||
} catch (error) {
|
||||
setErrorMessage(t(Keys.connectExplorer.errors.connectivityUnreachable));
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -147,7 +173,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
setConnectionString(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{errorMessage.length > 0 && (
|
||||
{errorMessage && (
|
||||
<span className="errorDetailsInfoTooltip">
|
||||
<img className="errorImg" src={ErrorImage} alt="Error notification" />
|
||||
<span className="errorDetails">{errorMessage}</span>
|
||||
@@ -169,6 +195,12 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
||||
Connect to your account with connection string
|
||||
</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<span className="errorDetailsInfoTooltip">
|
||||
<img className="errorImg" src={ErrorImage} alt="Error notification" />
|
||||
<span className="errorDetails">{errorMessage}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,27 +65,35 @@
|
||||
visibility: visible;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
|
||||
bottom: 24px;
|
||||
width: 165px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 100%;
|
||||
margin-left: 10px;
|
||||
width: 320px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
visibility: hidden;
|
||||
background-color: #393939;
|
||||
color: #ffffff;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: -10px;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
|
||||
border-width: 10px 10px 0px 10px;
|
||||
bottom: -8px;
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover:after {
|
||||
border-width: 8px 8px 8px 0px;
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 100%;
|
||||
margin-left: 2px;
|
||||
border-style: solid;
|
||||
left: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-color: #3b3b3b transparent;
|
||||
border-color: transparent #393939;
|
||||
}
|
||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
|
||||
height: 14px;
|
||||
|
||||
@@ -203,4 +203,38 @@ describe("AuthorizationUtils", () => {
|
||||
expect(AuthorizationUtils.getRedirectBridgeUrl()).toBe("https://cosmos.azure.com/redirectBridge.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizationError()", () => {
|
||||
it.each([401, 403])("treats a Cosmos client `code` of %i as an authorization failure", (statusCode) => {
|
||||
expect(
|
||||
AuthorizationUtils.isAuthorizationError(Object.assign(new Error("Unauthorized"), { code: statusCode })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([401, 403])("treats a `statusCode` of %i as an authorization failure", (statusCode) => {
|
||||
expect(AuthorizationUtils.isAuthorizationError(Object.assign(new Error("Unauthorized"), { statusCode }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a status reported as a string", () => {
|
||||
expect(AuthorizationUtils.isAuthorizationError({ code: "403" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([404, 429, 500, 503])("does not treat %i as an authorization failure", (statusCode) => {
|
||||
expect(AuthorizationUtils.isAuthorizationError({ code: statusCode })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a non numeric code as an authorization failure", () => {
|
||||
expect(AuthorizationUtils.isAuthorizationError({ code: "ENOTFOUND" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a network failure as an authorization failure", () => {
|
||||
expect(AuthorizationUtils.isAuthorizationError(new Error("Failed to fetch"))).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a missing error", () => {
|
||||
expect(AuthorizationUtils.isAuthorizationError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,3 +254,14 @@ export function isCloudShellEntraAuthEnabled(userContext: UserContext): boolean
|
||||
useDataplaneRbacAuthorization(userContext) || userContext.databaseAccount?.properties?.disableLocalAuth === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is an authorization failure (401 Unauthorized or 403 Forbidden). The Cosmos client
|
||||
* reports the HTTP status on `ErrorResponse.code`; errors raised from a `fetch` response carry it on
|
||||
* `statusCode`.
|
||||
*/
|
||||
export function isAuthorizationError(error: unknown): boolean {
|
||||
const { code, statusCode } = (error ?? {}) as { code?: unknown; statusCode?: unknown };
|
||||
const status = Number(statusCode ?? code);
|
||||
return status === Constants.HttpStatusCodes.Unauthorized || status === Constants.HttpStatusCodes.Forbidden;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user