diff --git a/.eslintignore b/.eslintignore index a0943ff18..8d665326b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -241,7 +241,6 @@ src/Platform/Hosted/Authorization.ts src/Platform/Hosted/DataAccessUtility.ts src/Platform/Hosted/ExplorerFactory.ts src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts -src/Platform/Hosted/Helpers/ConnectionStringParser.ts src/Platform/Hosted/HostedUtils.test.ts src/Platform/Hosted/HostedUtils.ts src/Platform/Hosted/Main.ts diff --git a/src/Explorer/Controls/AccountSwitch/AccountSwitchComponent.tsx b/src/Explorer/Controls/AccountSwitch/AccountSwitchComponent.tsx index 5af0e352a..b2ff5be6e 100644 --- a/src/Explorer/Controls/AccountSwitch/AccountSwitchComponent.tsx +++ b/src/Explorer/Controls/AccountSwitch/AccountSwitchComponent.tsx @@ -1,10 +1,9 @@ import { StyleConstants } from "../../../Common/Constants"; -import { DatabaseAccount, Subscription } from "../../../Contracts/DataModels"; import * as React from "react"; import { DefaultButton, IButtonStyles, IButtonProps } from "office-ui-fabric-react/lib/Button"; import { IContextualMenuProps } from "office-ui-fabric-react/lib/ContextualMenu"; -import { Dropdown, IDropdownOption, IDropdownProps } from "office-ui-fabric-react/lib/Dropdown"; +import { Dropdown, IDropdownProps } from "office-ui-fabric-react/lib/Dropdown"; import { useSubscriptions } from "../../../hooks/useSubscriptions"; import { useDatabaseAccounts } from "../../../hooks/useDatabaseAccounts"; diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index 0ee8b82a1..270cba4fa 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -14,7 +14,6 @@ import * as React from "react"; import { render } from "react-dom"; import FeedbackIcon from "../images/Feedback.svg"; import ConnectIcon from "../images/HostedConnectwhite.svg"; -import ChevronRight from "../images/chevron-right.svg"; import "../less/hostedexplorer.less"; import { CommandButtonComponent } from "./Explorer/Controls/CommandButton/CommandButtonComponent"; import "./Explorer/Menus/NavBar/MeControlComponent.less"; @@ -22,12 +21,17 @@ import { useGraphPhoto } from "./hooks/useGraphPhoto"; import "./Shared/appInsights"; import { AccountSwitchComponent } from "./Explorer/Controls/AccountSwitch/AccountSwitchComponent"; import { AuthContext, AuthProvider } from "./contexts/authContext"; +import { usePortalAccessToken } from "./hooks/usePortalAccessToken"; +import { AuthType } from "./AuthType"; initializeIcons(); const App: React.FunctionComponent = () => { + const params = new URLSearchParams(window.location.search); + const encryptedToken = params && params.get("key"); + const encryptedTokenMetadata = usePortalAccessToken(encryptedToken); const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false); - const { isLoggedIn, login, account, logout } = React.useContext(AuthContext); + const { isLoggedIn, aadlogin: login, account, aadlogout: logout } = React.useContext(AuthContext); const [isConnectionStringVisible, { setTrue: showConnectionString }] = useBoolean(false); const photo = useGraphPhoto(); @@ -121,11 +125,17 @@ const App: React.FunctionComponent = () => { Microsoft Azure Cosmos DB - {isLoggedIn && account separator} + {(isLoggedIn || encryptedTokenMetadata?.accountName) && ( + account separator + )} {isLoggedIn && ( - REPLACE ME - Connection string mode; + + )} + {!isLoggedIn && encryptedTokenMetadata?.accountName && ( + + {encryptedTokenMetadata?.accountName} )} @@ -186,9 +196,27 @@ const App: React.FunctionComponent = () => { - {isLoggedIn ? ( -

LOGGED IN!

- ) : ( + {encryptedTokenMetadata && !isLoggedIn && ( + + )} + {!encryptedTokenMetadata && isLoggedIn && ( + + )} + {!isLoggedIn && !encryptedTokenMetadata && (
@@ -227,13 +255,6 @@ const App: React.FunctionComponent = () => {
)} - {/* */}
diff --git a/src/Main.tsx b/src/Main.tsx index 442a42ebf..9c20b4671 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -84,42 +84,16 @@ window.authType = AuthType.AAD; const App: React.FunctionComponent = () => { useEffect(() => { initializeConfiguration().then(config => { + let explorer: Explorer; if (config.platform === Platform.Hosted) { - try { - Hosted.initializeExplorer().then( - (explorer: Explorer) => { - applyExplorerBindings(explorer); - Hosted.configureTokenValidationDisplayPrompt(explorer); - }, - (error: unknown) => { - try { - const uninitializedExplorer: Explorer = Hosted.getUninitializedExplorerForGuestAccess(); - window.dataExplorer = uninitializedExplorer; - ko.applyBindings(uninitializedExplorer); - BindingHandlersRegisterer.registerBindingHandlers(); - if (window.authType !== AuthType.AAD) { - uninitializedExplorer.isRefreshingExplorer(false); - uninitializedExplorer.displayConnectExplorerForm(); - } - } catch (e) { - console.log(e); - } - console.error(error); - } - ); - } catch (e) { - console.log(e); - } + explorer = Hosted.initializeExplorer(); } else if (config.platform === Platform.Emulator) { window.authType = AuthType.MasterKey; - const explorer = Emulator.initializeExplorer(); - applyExplorerBindings(explorer); + explorer = Emulator.initializeExplorer(); } else if (config.platform === Platform.Portal) { - TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.Open, {}); - const explorer = Portal.initializeExplorer(); - TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.IFrameReady, {}); - applyExplorerBindings(explorer); + explorer = Portal.initializeExplorer(); } + applyExplorerBindings(explorer); }); }, []); diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts index 0291396d1..da534c045 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts @@ -1,12 +1,12 @@ import * as DataModels from "../../../Contracts/DataModels"; -import { ConnectionStringParser } from "./ConnectionStringParser"; +import { parseConnectionString } from "./ConnectionStringParser"; describe("ConnectionStringParser", () => { const mockAccountName: string = "Test"; const mockMasterKey: string = "some-key"; it("should parse a valid sql account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};` ); @@ -15,7 +15,7 @@ describe("ConnectionStringParser", () => { }); it("should parse a valid mongo account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255` ); @@ -24,7 +24,7 @@ describe("ConnectionStringParser", () => { }); it("should parse a valid compute mongo account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255` ); @@ -33,7 +33,7 @@ describe("ConnectionStringParser", () => { }); it("should parse a valid graph account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;` ); @@ -42,7 +42,7 @@ describe("ConnectionStringParser", () => { }); it("should parse a valid table account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;` ); @@ -51,7 +51,7 @@ describe("ConnectionStringParser", () => { }); it("should parse a valid cassandra account connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( + const metadata = parseConnectionString( `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};` ); @@ -60,15 +60,13 @@ describe("ConnectionStringParser", () => { }); it("should fail to parse an invalid connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString( - "some-rogue-connection-string" - ); + const metadata = parseConnectionString("some-rogue-connection-string"); expect(metadata).toBe(undefined); }); it("should fail to parse an empty connection string", () => { - const metadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(""); + const metadata = parseConnectionString(""); expect(metadata).toBe(undefined); }); diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 423a85f09..861f724d1 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -1,50 +1,48 @@ import * as Constants from "../../../Common/Constants"; -import * as DataModels from "../../../Contracts/DataModels"; +import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; -export class ConnectionStringParser { - public static parseConnectionString(connectionString: string): DataModels.AccessInputMetadata { - if (!!connectionString) { - try { - const accessInput: DataModels.AccessInputMetadata = {} as DataModels.AccessInputMetadata; - const connectionStringParts = connectionString.split(";"); +export function parseConnectionString(connectionString: string): AccessInputMetadata { + if (connectionString) { + try { + const accessInput = {} as AccessInputMetadata; + const connectionStringParts = connectionString.split(";"); - connectionStringParts.forEach((connectionStringPart: string) => { - if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1]; - accessInput.apiKind = DataModels.ApiKind.SQL; - } else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo); - accessInput.accountName = matches && matches.length > 1 && matches[2]; - accessInput.apiKind = DataModels.ApiKind.MongoDB; - } else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); - accessInput.accountName = matches && matches.length > 1 && matches[2]; - accessInput.apiKind = DataModels.ApiKind.MongoDBCompute; - } else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) { - Constants.EndpointsRegex.cassandra.forEach(regex => { - if (RegExp(regex).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(regex)[1]; - accessInput.apiKind = DataModels.ApiKind.Cassandra; - } - }); - } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; - accessInput.apiKind = DataModels.ApiKind.Table; - } else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) { - accessInput.apiKind = DataModels.ApiKind.Graph; - } - }); - - if (Object.keys(accessInput).length === 0) { - return undefined; + connectionStringParts.forEach((connectionStringPart: string) => { + if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) { + accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1]; + accessInput.apiKind = ApiKind.SQL; + } else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo); + accessInput.accountName = matches && matches.length > 1 && matches[2]; + accessInput.apiKind = ApiKind.MongoDB; + } else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); + accessInput.accountName = matches && matches.length > 1 && matches[2]; + accessInput.apiKind = ApiKind.MongoDBCompute; + } else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) { + Constants.EndpointsRegex.cassandra.forEach(regex => { + if (RegExp(regex).test(connectionStringPart)) { + accessInput.accountName = connectionStringPart.match(regex)[1]; + accessInput.apiKind = ApiKind.Cassandra; + } + }); + } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { + accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; + accessInput.apiKind = ApiKind.Table; + } else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) { + accessInput.apiKind = ApiKind.Graph; } + }); - return accessInput; - } catch (error) { + if (Object.keys(accessInput).length === 0) { return undefined; } - } - return undefined; + return accessInput; + } catch (error) { + return undefined; + } } + + return undefined; } diff --git a/src/Platform/Hosted/Main.ts b/src/Platform/Hosted/Main.ts index 672efba95..b1c6859fa 100644 --- a/src/Platform/Hosted/Main.ts +++ b/src/Platform/Hosted/Main.ts @@ -43,84 +43,40 @@ export default class Main { return false; } - public static initializeExplorer(): Q.Promise { + public static initializeExplorer(): Explorer { window.addEventListener("message", this._handleMessage.bind(this), false); this._features = {}; - const params = new URLSearchParams(window.parent.location.search); - const deferred: Q.Deferred = Q.defer(); - let authType: string = null; - - // Encrypted token flow - if (!!params && params.has("key")) { - Main._encryptedToken = encodeURIComponent(params.get("key")); - SessionStorageUtility.setEntryString(StorageKey.EncryptedKeyToken, Main._encryptedToken); - authType = AuthType.EncryptedToken; - } else if (Main._hasCachedEncryptedKey()) { - Main._encryptedToken = SessionStorageUtility.getEntryString(StorageKey.EncryptedKeyToken); - authType = AuthType.EncryptedToken; - } - - // Aad flow - if (AuthHeadersUtil.isUserSignedIn()) { - authType = AuthType.AAD; - } + const params = new URLSearchParams(window.location.search); + let authType: string = params && params.get("authType"); if (params) { this._features = Main.extractFeatures(params); } + // Encrypted token flow + if (params && params.has("key")) { + Main._encryptedToken = encodeURIComponent(params.get("key")); + Main._accessInputMetadata = JSON.parse(params.get("metadata")); + authType = AuthType.EncryptedToken; + } + (window).authType = authType; if (!authType) { - return Q.reject("Sign in needed"); + throw new Error("Sign in needed"); } const explorer: Explorer = this._instantiateExplorer(); if (authType === AuthType.EncryptedToken) { - sendMessage({ - type: MessageTypes.UpdateAccountSwitch, - props: { - authType: AuthType.EncryptedToken, - displayText: "Loading..." - } - }); updateUserContext({ accessToken: Main._encryptedToken }); - Main._getAccessInputMetadata(Main._encryptedToken).then( - () => { - const expiryTimestamp: number = - Main._accessInputMetadata && parseInt(Main._accessInputMetadata.expiryTimestamp); - if (authType === AuthType.EncryptedToken && (isNaN(expiryTimestamp) || expiryTimestamp <= 0)) { - return deferred.reject("Token expired"); - } - - Main._initDataExplorerFrameInputs(explorer); - deferred.resolve(explorer); - }, - (error: any) => { - console.error(error); - deferred.reject(error); - } - ); + Main._initDataExplorerFrameInputs(explorer); } else if (authType === AuthType.AAD) { - sendMessage({ - type: MessageTypes.GetAccessAadRequest - }); - if (this._getAadAccessDeferred != null) { - // already request aad access, don't duplicate - return Q(null); - } this._explorer = explorer; - this._getAadAccessDeferred = Q.defer(); - return this._getAadAccessDeferred.promise.finally(() => { - this._getAadAccessDeferred = null; - }); } else { Main._initDataExplorerFrameInputs(explorer); - deferred.resolve(explorer); } - - return deferred.promise; + return explorer; } public static extractFeatures(params: URLSearchParams): { [key: string]: string } { @@ -137,21 +93,6 @@ export default class Main { return features; } - public static configureTokenValidationDisplayPrompt(explorer: Explorer): void { - const authType: AuthType = (window).authType; - if ( - !explorer || - !Main._encryptedToken || - !Main._accessInputMetadata || - Main._accessInputMetadata.expiryTimestamp == null || - authType !== AuthType.EncryptedToken - ) { - return; - } - - Main._showGuestAccessTokenRenewalPromptInMs(explorer, parseInt(Main._accessInputMetadata.expiryTimestamp)); - } - public static parseResourceTokenConnectionString(connectionString: string): resourceTokenConnectionStringProperties { let accountEndpoint: string; let collectionId: string; @@ -234,7 +175,6 @@ export default class Main { } const masterKey: string = Main._getMasterKeyFromConnectionString(connectionString); - Main.configureTokenValidationDisplayPrompt(explorer); Main._setExplorerReady(explorer, masterKey); deferred.resolve(); @@ -398,14 +338,6 @@ export default class Main { return explorer; } - private static _showGuestAccessTokenRenewalPromptInMs(explorer: Explorer, interval: number): void { - if (interval != null && !isNaN(interval)) { - setTimeout(() => { - explorer.displayGuestAccessTokenRenewalPrompt(); - }, interval); - } - } - private static _hasCachedEncryptedKey(): boolean { return SessionStorageUtility.hasItem(StorageKey.EncryptedKeyToken); } diff --git a/src/contexts/authContext.tsx b/src/contexts/authContext.tsx index c792b9eef..a8e1a07b1 100644 --- a/src/contexts/authContext.tsx +++ b/src/contexts/authContext.tsx @@ -9,7 +9,7 @@ const msal = new Msal.UserAgentApplication({ auth: { authority: "https://login.microsoft.com/common", clientId: "203f1145-856a-4232-83d4-a43568fba23d", - redirectUri: "https://dataexplorer-dev.azurewebsites.net" + redirectUri: "https://dataexplorer-dev.azurewebsites.net" // TODO! This should only be set development } }); @@ -18,16 +18,16 @@ interface AuthContext { account?: Msal.Account; graphToken?: string; armToken?: string; - logout: () => unknown; - login: () => unknown; + aadlogout: () => unknown; + aadlogin: () => unknown; } export const AuthContext = createContext({ isLoggedIn: false, - login: () => { + aadlogin: () => { throw Error(defaultError); }, - logout: () => { + aadlogout: () => { throw Error(defaultError); } }); @@ -38,36 +38,27 @@ export const AuthProvider: React.FunctionComponent = ({ children }) => { const [graphToken, setGraphToken] = useState(); const [armToken, setArmToken] = useState(); - const login = useCallback(() => { - msal.loginPopup().then(response => { - setLoggedIn(); - setAccount(response.account); - msal - .acquireTokenSilent({ scopes: ["https://graph.windows.net//.default"] }) - .then(resp => { - setGraphToken(resp.accessToken); - }) - .catch(e => { - console.error(e); - }); - msal - .acquireTokenSilent({ scopes: ["https://management.azure.com//.default"] }) - .then(resp => { - setArmToken(resp.accessToken); - }) - .catch(e => { - console.error(e); - }); - }); + const aadlogin = useCallback(async () => { + const response = await msal.loginPopup(); + setLoggedIn(); + setAccount(response.account); + + const [graphTokenResponse, armTokenResponse] = await Promise.all([ + msal.acquireTokenSilent({ scopes: ["https://graph.windows.net//.default"] }), + msal.acquireTokenSilent({ scopes: ["https://management.azure.com//.default"] }) + ]); + + setGraphToken(graphTokenResponse.accessToken); + setArmToken(armTokenResponse.accessToken); }, []); - const logout = useCallback(() => { + const aadlogout = useCallback(() => { msal.logout(); setLoggedOut(); }, []); return ( - + {children} ); diff --git a/src/hooks/usePortalAccessToken.tsx b/src/hooks/usePortalAccessToken.tsx new file mode 100644 index 000000000..994192031 --- /dev/null +++ b/src/hooks/usePortalAccessToken.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { ApiEndpoints } from "../Common/Constants"; +import { configContext } from "../ConfigContext"; +import { AccessInputMetadata } from "../Contracts/DataModels"; + +const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`; + +export async function fetchAccessData(portalToken: string): Promise { + const headers = new Headers(); + // Portal encrypted token API quirk: The token header must be URL encoded + headers.append("x-ms-encrypted-auth-token", encodeURIComponent(portalToken)); + + const options = { + method: "GET", + headers: headers + }; + + return ( + fetch(url, options) + .then(response => response.json()) + // Portal encrypted token API quirk: The response is double JSON encoded + .then(json => JSON.parse(json)) + .catch(error => console.log(error)) + ); +} + +export function usePortalAccessToken(token: string): AccessInputMetadata { + const [state, setState] = useState(); + + useEffect(() => { + if (token) { + fetchAccessData(token).then(response => setState(response)); + } + }, [token]); + return state; +}