diff --git a/LICENSES.txt b/LICENSES.txt index 3dae7151dc4..f815e680aa1 100644 --- a/LICENSES.txt +++ b/LICENSES.txt @@ -5805,6 +5805,32 @@ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEAL ----- +The following software may be included in this product: jwt-decode. A copy of the source code may be downloaded from git://github.com/auth0/jwt-decode. This software contains the following license and notice below: + +The MIT License (MIT) + +Copyright (c) 2015 Auth0, Inc. (http://auth0.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----- + The following software may be included in this product: keyboard-key. A copy of the source code may be downloaded from git+ssh://github.com/levithomason/keyboard-key.git. This software contains the following license and notice below: MIT License diff --git a/NOTICE.txt b/NOTICE.txt index e62ff30642f..a28b197c295 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1275,6 +1275,10 @@ Third-party licenses │ ├─ jws@4.0.0 │ │ ├─ URL: git://github.com/brianloveswords/node-jws.git │ │ └─ VendorName: Brian J Brennan +│ ├─ jwt-decode@3.1.2 +│ │ ├─ URL: git://github.com/auth0/jwt-decode +│ │ ├─ VendorName: Jose F. Romaniello +│ │ └─ VendorUrl: https://github.com/auth0/jwt-decode#readme │ ├─ keyboard-key@1.1.0 │ │ ├─ URL: git+ssh://github.com/levithomason/keyboard-key.git │ │ └─ VendorName: Levi Thomason diff --git a/package.json b/package.json index 70881d2e50f..7b07f79c645 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "isomorphic-fetch": "^2.2.1", "jsonic": "^0.3.0", "jszip": "^3.2.2", + "jwt-decode": "^3.1.2", "lodash-es": "^4.17.15", "memoize-one": "^5.2.1", "monaco-editor": "0.23.0", diff --git a/src/browser/AppInit.tsx b/src/browser/AppInit.tsx index edbc80ece0e..75593dbb9b9 100644 --- a/src/browser/AppInit.tsx +++ b/src/browser/AppInit.tsx @@ -53,6 +53,10 @@ import { shouldAllowOutgoingConnections } from 'shared/modules/dbMeta/dbMetaDuck import { getUuid } from 'shared/modules/udc/udcDuck' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' +import { + restoreSearchAndHashParams, + wasRedirectedBackFromSSOServer +} from 'shared/modules/auth/common' // Configure localstorage sync applyKeys( @@ -199,6 +203,14 @@ export function setupSentry(): void { // Introduce environment to be able to fork functionality const env = detectRuntimeEnv(window, NEO4J_CLOUD_DOMAINS) +// SSO requires a redirect that removes our search parameters +// To work around this they are stored in sessionStorage before +// we redirect to the server, and then restore them when we get +// redirected back +if (wasRedirectedBackFromSSOServer()) { + restoreSearchAndHashParams() +} + // URL we're on const url = window.location.href diff --git a/src/browser/components/buttons/index.tsx b/src/browser/components/buttons/index.tsx index fd3d7ab351d..2c84a899497 100644 --- a/src/browser/components/buttons/index.tsx +++ b/src/browser/components/buttons/index.tsx @@ -115,8 +115,8 @@ export const NavigationButtonContainer = styled.li<{ isOpen: boolean }>` const StyledFormButton = styled.button` color: ${props => props.theme.primaryButtonText}; - background-color: ${props => props.theme.primaryButtonBackground}; - border: 1px solid ${props => props.theme.primaryButtonBackground}; + background-color: ${props => props.theme.primary}; + border: 1px solid ${props => props.theme.primary}; font-family: ${props => props.theme.primaryFontFamily}; padding: 6px 18px; margin-right: 10px; @@ -129,9 +129,9 @@ const StyledFormButton = styled.button` border-radius: 4px; line-height: 20px; &:hover { - background-color: ${props => props.theme.secondaryButtonBackgroundHover}; + background-color: ${props => props.theme.primary50}; color: ${props => props.theme.secondaryButtonTextHover}; - border: 1px solid ${props => props.theme.secondaryButtonBackgroundHover}; + border: 1px solid ${props => props.theme.primary50}; } ` diff --git a/src/browser/components/headers/Headers.tsx b/src/browser/components/headers/Headers.tsx index 4ef9952b873..06bda2b9628 100644 --- a/src/browser/components/headers/Headers.tsx +++ b/src/browser/components/headers/Headers.tsx @@ -26,3 +26,10 @@ export const H3 = styled.h3` font-family: ${props => props.theme.primaryFontFamily}; color: ${props => props.theme.headerText}; ` +export const H4 = styled.h3` + font-weight: 500; + font-size: 18px; + font-family: ${props => props.theme.primaryFontFamily}; + color: ${props => props.theme.headerText}; + margin-bottom: 32px; +` diff --git a/src/browser/modules/Stream/Auth/ConnectForm.tsx b/src/browser/modules/Stream/Auth/ConnectForm.tsx index cb99555011c..4c4aaa8d524 100644 --- a/src/browser/modules/Stream/Auth/ConnectForm.tsx +++ b/src/browser/modules/Stream/Auth/ConnectForm.tsx @@ -28,12 +28,24 @@ import { StyledConnectionLabel, StyledConnectionFormEntry, StyledSegment, - StyledBoltUrlHintText + StyledBoltUrlHintText, + StyledFormContainer, + StyledSSOOptions, + StyledSSOButtonContainer, + StyledSSOError, + StyledSSOLogDownload } from './styled' import { NATIVE, NO_AUTH } from 'services/bolt/boltHelpers' import { toKeyString } from 'services/utils' import { stripScheme, getScheme } from 'services/boltscheme.utils' -import { AuthenticationMethod } from 'shared/modules/connections/connectionsDuck' +import { + AuthenticationMethod, + SSOProvider +} from 'shared/modules/connections/connectionsDuck' +import { authRequestForSSO } from 'shared/modules/auth/index.js' +import { StyledCypherErrorMessage } from '../styled' +import { authLog, downloadAuthLogs } from 'shared/modules/auth/helpers' +import { H4 } from 'browser-components/headers/Headers' const readableauthenticationMethods: Record = { [NATIVE]: 'Username / Password', @@ -56,6 +68,8 @@ interface ConnectFormProps { username: string used: boolean supportsMultiDb: boolean + SSOError?: string + SSOProviders: SSOProvider[] } export default function ConnectForm(props: ConnectFormProps): JSX.Element { @@ -116,118 +130,155 @@ export default function ConnectForm(props: ConnectFormProps): JSX.Element { }:// for a direct connection to a single instance.` : '' + const { SSOError, SSOProviders } = props + + const showSSO = SSOProviders.length > 0 || SSOError + const [SSORedirectError, setRedirectError] = useState('') + return ( - - - - Connect URL - - {schemeRestriction ? ( - <> - - + {showSSO && ( + +

Single sign-on

+ {SSOProviders.map((provider: SSOProvider) => ( + + + authRequestForSSO(provider).catch(e => { + authLog(e.message) + setRedirectError(e.message) + }) + } + style={{ width: '200px' }} > - {props.allowedSchemes.map(s => { - const schemeString = `${s}://` - return ( - - ) - })} -
- -
- - {hoverText} - - - ) : ( - - )} -
- {props.supportsMultiDb && ( - - - Database - leave empty for default - - - + {provider.name} + + + ))} + {(SSOError || SSORedirectError) && ( + + ERROR +
{SSOError || SSORedirectError}
+ + Download logs + +
+ )} + )} - - {props.allowedAuthMethods.length > 1 && ( + - - Authentication type - - {props.allowedAuthMethods.map(auth => ( - - ))} - + {showSSO &&

Login with Password

} + + Connect URL -
- )} - - {props.authenticationMethod === NATIVE && ( - - - Username + {schemeRestriction ? ( + <> + + + {props.allowedSchemes.map(s => { + const schemeString = `${s}://` + return ( + + ) + })} + + + + + {hoverText} + + + ) : ( - + )} - )} + {props.supportsMultiDb && ( + + + Database - leave empty for default + + + + )} - {props.authenticationMethod === NATIVE && ( - - - Password - - - - )} + {props.allowedAuthMethods.length > 1 && ( + + + Authentication type + + {props.allowedAuthMethods.map(auth => ( + + ))} + + + + )} + + {props.authenticationMethod === NATIVE && ( + + + Username + + + + )} + + {props.authenticationMethod === NATIVE && ( + + + Password + + + + )} - - - Connect - - - Connecting... -
+ + + Connect + + + Connecting... +
+ ) } diff --git a/src/browser/modules/Stream/Auth/ConnectionForm.tsx b/src/browser/modules/Stream/Auth/ConnectionForm.tsx index 8defca8deb8..ce3d9296183 100644 --- a/src/browser/modules/Stream/Auth/ConnectionForm.tsx +++ b/src/browser/modules/Stream/Auth/ConnectionForm.tsx @@ -345,6 +345,8 @@ export class ConnectionForm extends Component { this )} host={host} + SSOError={this.state.SSOError} + SSOProviders={this.state.SSOProviders || []} username={this.state.username} password={this.state.password} database={this.state.requestedUseDb} diff --git a/src/browser/modules/Stream/Auth/styled.tsx b/src/browser/modules/Stream/Auth/styled.tsx index df7f99f3bb3..37efccec242 100644 --- a/src/browser/modules/Stream/Auth/styled.tsx +++ b/src/browser/modules/Stream/Auth/styled.tsx @@ -25,6 +25,7 @@ import { StyledFrameAside } from '../../Frame/styled' export const StyledConnectionForm = styled.form` padding: 0 15px; + flex: 1; &.isLoading { opacity: 0.5; } @@ -149,3 +150,27 @@ export const StyledCode = styled.code` ` export const StyledDbsRow = styled.li`` + +export const StyledFormContainer = styled.div` + display: flex; +` + +export const StyledSSOOptions = styled.div` + border-right: 1px solid rgb(77, 74, 87, 0.3); + padding-right: 60px; + margin-right: 60px; + margin-left: 30px; + max-width: 300px; +` + +export const StyledSSOLogDownload = styled.a` + cursor: pointer; +` +export const StyledSSOButtonContainer = styled.div` + margin-bottom: 12px; +` +export const StyledSSOError = styled.div` + margin-top: 30px; + padding: 3px; + white-space: pre-line; +` diff --git a/src/browser/modules/Stream/__snapshots__/SchemaFrame.test.tsx.snap b/src/browser/modules/Stream/__snapshots__/SchemaFrame.test.tsx.snap index 5b6d2f19ad4..603d2326a1d 100644 --- a/src/browser/modules/Stream/__snapshots__/SchemaFrame.test.tsx.snap +++ b/src/browser/modules/Stream/__snapshots__/SchemaFrame.test.tsx.snap @@ -10,13 +10,13 @@ exports[`SchemaFrame renders empty 1`] = ` class="sc-dqBHgY eVMXHH" > @@ -24,10 +24,10 @@ exports[`SchemaFrame renders empty 1`] = ` @@ -35,13 +35,13 @@ exports[`SchemaFrame renders empty 1`] = `
Indexes
None
@@ -49,10 +49,10 @@ exports[`SchemaFrame renders empty 1`] = ` @@ -92,43 +92,43 @@ exports[`SchemaFrame renders empty for Neo4j >= 4.0 1`] = ` class="sc-dqBHgY eVMXHH" >
Constraints
None
@@ -136,42 +136,42 @@ exports[`SchemaFrame renders empty for Neo4j >= 4.0 1`] = `
Index Name Type Uniqueness EntityType LabelsOrTypes Properties State
None
@@ -179,10 +179,10 @@ exports[`SchemaFrame renders empty for Neo4j >= 4.0 1`] = ` @@ -222,13 +222,13 @@ exports[`SchemaFrame renders results for Neo4j < 4.0 1`] = ` class="sc-dqBHgY eVMXHH" >
Constraints
None
@@ -236,10 +236,10 @@ exports[`SchemaFrame renders results for Neo4j < 4.0 1`] = ` @@ -247,13 +247,13 @@ exports[`SchemaFrame renders results for Neo4j < 4.0 1`] = `
Indexes
ON :Movie(released) ONLINE
@@ -261,10 +261,10 @@ exports[`SchemaFrame renders results for Neo4j < 4.0 1`] = ` diff --git a/src/browser/styles/themes.ts b/src/browser/styles/themes.ts index cb275069ae7..39030787f1c 100644 --- a/src/browser/styles/themes.ts +++ b/src/browser/styles/themes.ts @@ -40,6 +40,10 @@ export const base = { neo4jBlue: '#018BFF', darkBlue: '#0056B3', + // Design system colors + primary: '#018BFF', + primary50: '#0070d9', + // Backgrounds primaryBackground: '#D2D5DA', secondaryBackground: '#fff', diff --git a/src/shared/modules/auth/common.js b/src/shared/modules/auth/common.js new file mode 100644 index 00000000000..002214e28b0 --- /dev/null +++ b/src/shared/modules/auth/common.js @@ -0,0 +1,192 @@ +import jwtDecode from 'jwt-decode' +import { isObject } from 'lodash-es' +import { + AUTH_STORAGE_URL_SEARCH_PARAMS, + REDIRECT_URI, + SSO_REDIRECT +} from './constants' +import { addSearchParamsInBrowserHistory, authLog, authDebug } from './helpers' +import { + defaultTokenTypeAuthentication, + defaultTokenTypePrincipal, + mandatoryKeysForSSOProviderParams, + mandatoryKeysForSSOProviders +} from './settings' + +export const getInitialisationParameters = () => { + const urlSearchParams = window.location.search + const urlHashParamsAsSearchParams = '?' + window.location.hash.substring(1) + + const initParams = {} + new URLSearchParams(urlSearchParams).forEach((value, key) => { + initParams[key] = value + }) + new URLSearchParams(urlHashParamsAsSearchParams).forEach((value, key) => { + initParams[key] = value + }) + + return initParams +} + +export const getValidSSOProviders = discoveredSSOProviders => { + if (!discoveredSSOProviders) { + return [] + } + + if (!Array.isArray(discoveredSSOProviders)) { + authLog( + `Discovered SSO providers should be a list, got ${discoveredSSOProviders}`, + 'warn' + ) + } + + if (discoveredSSOProviders.length === 0) { + authLog('List of discovered SSO providers was empty', 'warn') + return [] + } + + const validSSOProviders = discoveredSSOProviders.filter(provider => { + const missingKeys = mandatoryKeysForSSOProviders.filter( + key => !provider.hasOwnProperty(key) + ) + if (missingKeys.length !== 0) { + authLog( + `dropping invalid discovered sso provider with id: "${ + provider.id + }", missing key(s) ${missingKeys.join(', ')} ` + ) + return false + } + + const missingParamKeys = mandatoryKeysForSSOProviderParams.filter( + key => !provider.params.hasOwnProperty(key) + ) + if (missingParamKeys.length !== 0) { + authLog( + `Dropping invalid discovered SSO provider with id: "${ + provider.id + }", missing params key(s) ${missingKeys.join(', ')}` + ) + return false + } + + return true + }) + + authLog('Checked SSO providers') + return validSSOProviders +} + +export const getCredentialsFromAuthResult = (result, selectedSSOProvider) => { + authLog( + `Attempting to assemble credentials for idp_id: ${selectedSSOProvider.id}` + ) + if (!selectedSSOProvider) { + throw new Error('No SSO provider passed') + } + + if (!result) { + throw new Error('Missing result in auth result handler') + } + + const tokenTypePrincipal = + selectedSSOProvider.config?.['token_type_principal'] || + defaultTokenTypePrincipal + + authLog( + `Credentials, using token type "${tokenTypePrincipal}" to retrieve principal` + ) + + let parsedJWT + try { + parsedJWT = jwtDecode(result[tokenTypePrincipal]) + } catch (e) {} + + if (!parsedJWT) { + throw new Error( + `Could not parse JWT of type "${tokenTypePrincipal}" for idp_id "${selectedSSOProvider.id}", aborting` + ) + } + authDebug('Credentials, parsed JWT', parsedJWT) + + const principal = selectedSSOProvider.config?.principal + if (principal) { + authLog(`Credentials, provided principal in config: ${principal}`) + } else { + authLog( + `Credentials, no principal provided in config, falling back to 'username' then 'sub'` + ) + } + + const credsPrincipal = + parsedJWT[principal] || parsedJWT.email || parsedJWT.sub + authLog(`Credentials assembly with username: ${credsPrincipal}`) + + const configuredTokenType = + selectedSSOProvider.config?.['token_type_authentication'] + const tokenTypeAuthentication = + configuredTokenType || defaultTokenTypeAuthentication + + if (!configuredTokenType) { + authLog( + `token_type_authentication not configured, using default token type "${defaultTokenTypeAuthentication}".` + ) + } + + authLog( + `Credentials assembled with token type "${tokenTypeAuthentication}" as password. If connection still does not succeed, make sure neo4j.conf is set up correctly` + ) + + return { username: credsPrincipal, password: result[tokenTypeAuthentication] } +} + +export const temporarilyStoreUrlSearchParams = () => { + const currentBrowserURLParams = getInitialisationParameters() + authLog( + `Temporarily storing the url search params. data: "${JSON.stringify( + currentBrowserURLParams + )}"` + ) + window.sessionStorage.setItem( + AUTH_STORAGE_URL_SEARCH_PARAMS, + JSON.stringify(currentBrowserURLParams) + ) +} + +export const getSSOServerIdIfShouldRedirect = () => { + const { searchParams } = new URL(window.location.href) + return searchParams.get(SSO_REDIRECT) +} + +export const wasRedirectedBackFromSSOServer = () => { + const { auth_flow_step: authFlowStep } = getInitialisationParameters() + return (authFlowStep || '').toLowerCase() === REDIRECT_URI +} + +export const restoreSearchAndHashParams = () => { + authLog(`Retrieving temporarily stored url search params`) + try { + const storedParams = JSON.parse( + window.sessionStorage.getItem(AUTH_STORAGE_URL_SEARCH_PARAMS) + ) + + window.sessionStorage.setItem(AUTH_STORAGE_URL_SEARCH_PARAMS, '') + + if (!isObject(storedParams)) { + throw new Error( + `Stored search params were ${storedParams}, expected an object` + ) + } + const crntHashParams = window.location.hash || undefined + addSearchParamsInBrowserHistory(storedParams) + const newUrl = `${window.location.href}${crntHashParams || ''}` + window.history.replaceState({}, '', newUrl) + return storedParams + } catch (err) { + authLog( + `Error when parsing temporarily stored url search params, err: ${err}. Clearing.` + ) + window.sessionStorage.setItem(AUTH_STORAGE_URL_SEARCH_PARAMS, '') + return null + } +} diff --git a/src/shared/modules/auth/constants.js b/src/shared/modules/auth/constants.js new file mode 100644 index 00000000000..49a2e29f010 --- /dev/null +++ b/src/shared/modules/auth/constants.js @@ -0,0 +1,13 @@ +export const SSO_REDIRECT = 'sso_redirect' +export const REDIRECT_URI = 'redirect_uri' +export const BEARER = 'bearer' +export const PKCE = 'pkce' +export const IMPLICIT = 'implicit' + +export const AUTH_LOGGING_PREFIX = 'OIDC/OAuth#' + +const AUTH_STORAGE_PREFIX = '/auth#' +export const AUTH_STORAGE_STATE = `${AUTH_STORAGE_PREFIX}state` +export const AUTH_STORAGE_CODE_VERIFIER = `${AUTH_STORAGE_PREFIX}code_verifier` +export const AUTH_STORAGE_URL_SEARCH_PARAMS = `${AUTH_STORAGE_PREFIX}url_search_params` +export const AUTH_STORAGE_LOGS = `${AUTH_STORAGE_PREFIX}logs` diff --git a/src/shared/modules/auth/helpers.js b/src/shared/modules/auth/helpers.js new file mode 100644 index 00000000000..1db8cc9cd2a --- /dev/null +++ b/src/shared/modules/auth/helpers.js @@ -0,0 +1,141 @@ +import { isObject } from 'lodash' +import { AUTH_LOGGING_PREFIX, AUTH_STORAGE_LOGS } from './constants' +import { isAuthLoggingEnabled, isAuthDebuggingEnabled } from './settings' +import { saveAs } from 'file-saver' + +const MAX_LOG_LINES = 200 +export const authLog = (msg, type = 'log') => { + if (!isAuthLoggingEnabled) return + if (!['log', 'error', 'warn'].includes(type)) return + const messageNoNewlines = msg.replace('\n', ' ') + const log = `${AUTH_LOGGING_PREFIX} [${new Date().toISOString()}] ${messageNoNewlines}` + const logs = sessionStorage.getItem(AUTH_STORAGE_LOGS) || '' + const logsLines = logs.split('\n') + + const truncatedOldLogs = + logsLines.length > MAX_LOG_LINES + ? logsLines.slice(1 - MAX_LOG_LINES).join('\n') + : logs + + sessionStorage.setItem(AUTH_STORAGE_LOGS, `${truncatedOldLogs}${log}\n`) +} + +export const downloadAuthLogs = () => { + const blob = new Blob([sessionStorage.getItem(AUTH_STORAGE_LOGS)], { + type: 'text/plain;charset=utf-8' + }) + saveAs(blob, 'neo4j-browser-sso.log') +} + +export const authDebug = (msg, content) => { + if (!isAuthDebuggingEnabled) return + console.log(`${AUTH_LOGGING_PREFIX} - DEBUG - ${msg}`) + console.dir(content) +} + +export const createNonce = () => + Array.from(window.crypto.getRandomValues(new Uint32Array(4)), t => + t.toString(36) + ).join('-') + +export const createStateForRequest = () => { + const base = Array.from( + window.crypto.getRandomValues(new Uint32Array(4)), + t => t.toString(16) + ).join('-') + return `state-${base}` +} + +export const createCodeVerifier = method => { + switch (method) { + case 'plain': + case 'S256': + const randomString = Array.from( + window.crypto.getRandomValues(new Uint8Array(32)), + t => String.fromCharCode(t) + ).join('') + return _btoaUrlSafe(randomString) + case '': + case null: + default: + throw new Error( + `Unsupported or missing code verification method: ${method}` + ) + } +} + +export const createCodeChallenge = async (method, codeVerifier) => { + if (!codeVerifier) { + throw new Error( + 'Unable to create code challenger: Missing code verifier argument to createCodeChallenge' + ) + } + + switch (method) { + case 'plain': + return codeVerifier + case 'S256': + try { + let bytes = Uint8Array.from(codeVerifier, t => t.charCodeAt(0)) + bytes = await window.crypto.subtle.digest('SHA-256', bytes) + const stringFromBytes = Array.from(new Uint8Array(bytes), t => + String.fromCharCode(t) + ).join('') + return _btoaUrlSafe(stringFromBytes) + } catch (e) { + throw new Error(`Failed to create code challenge with error ${e}`) + } + case '': + case null: + default: + throw new Error(`Unsupported or missing code challenge method: ${method}`) + } +} + +export const addSearchParamsInBrowserHistory = paramsToAddObj => { + if (!paramsToAddObj || !isObject(paramsToAddObj)) return + + const crntHashParams = window.location.hash || undefined + const searchParams = new URLSearchParams(window.location.search) + Object.entries(paramsToAddObj).forEach(([key, value]) => { + if (key && value && value.length) { + searchParams.set(key, value) + } + }) + + const newUrl = `${ + window.location.origin + }?${searchParams.toString()}${crntHashParams || ''}` + window.history.replaceState({}, '', newUrl) +} + +export const removeSearchParamsInBrowserHistory = paramsToRemove => { + if (!paramsToRemove || !paramsToRemove.length) return + + const currentUrlSearchParams = new URLSearchParams(window.location.search) + const cleansedSearchParams = {} + for (const [key, value] of currentUrlSearchParams.entries()) { + if (!paramsToRemove.includes(key)) { + cleansedSearchParams[key] = value + } + } + const newUrlSearchParams = new URLSearchParams(cleansedSearchParams) + + const newUrl = `${window.location.origin}?${newUrlSearchParams.toString()}` + window.history.replaceState({}, '', newUrl) +} + +/* + * Encode text safely to Base64 url + * https://tools.ietf.org/html/rfc7515#appendix-C + * https://tools.ietf.org/html/rfc4648#section-5 + */ +const _btoaUrlSafe = text => { + if (!text) return null + + return window + .btoa(text) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') // remove trailing padding characters +} diff --git a/src/shared/modules/auth/helpers.test.js b/src/shared/modules/auth/helpers.test.js new file mode 100644 index 00000000000..96710f98ef0 --- /dev/null +++ b/src/shared/modules/auth/helpers.test.js @@ -0,0 +1,122 @@ +import { + addSearchParamsInBrowserHistory, + removeSearchParamsInBrowserHistory +} from './helpers' + +describe('addSearchParamsInBrowserHistory', () => { + const originalWindowLocationHref = 'http://localhost/' + + afterEach(() => { + window.history.replaceState({}, '', originalWindowLocationHref) + }) + + test('no args passed in', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + addSearchParamsInBrowserHistory() + expect(window.location.href).toEqual(originalWindowLocationHref) + }) + + test('empty passed in args', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + addSearchParamsInBrowserHistory({}) + expect(window.location.href).toEqual(originalWindowLocationHref + '?') + }) + + test('simple search parameter', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + addSearchParamsInBrowserHistory({ rest: 'test' }) + expect(window.location.href).toEqual( + originalWindowLocationHref + '?rest=test' + ) + }) + + test('search parameter with special characters', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + addSearchParamsInBrowserHistory({ test_entry: '5%73bsod?892()€%&#"€' }) + expect(window.location.href).toEqual( + originalWindowLocationHref + + '?test_entry=5%2573bsod%3F892%28%29%E2%82%AC%25%26%23%22%E2%82%AC' + ) + }) + + test('multiple search parameters', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + addSearchParamsInBrowserHistory({ + test_entry: '5%73bsod?892()€%&#"€', + entryUrl: 'http://localhost:8043/test/?entry=boat' + }) + expect(window.location.href).toEqual( + originalWindowLocationHref + + '?test_entry=5%2573bsod%3F892%28%29%E2%82%AC%25%26%23%22%E2%82%AC&entryUrl=http%3A%2F%2Flocalhost%3A8043%2Ftest%2F%3Fentry%3Dboat' + ) + }) + + test('keeps URL hash parameters', () => { + const hashUrlParams = '#code=df56&code_verifier=rt43' + window.history.replaceState( + {}, + '', + originalWindowLocationHref + hashUrlParams + ) + expect(window.location.href).toEqual( + originalWindowLocationHref + hashUrlParams + ) + addSearchParamsInBrowserHistory({ rest: 'test' }) + expect(window.location.href).toEqual( + originalWindowLocationHref + '?rest=test' + hashUrlParams + ) + }) +}) + +describe('removeSearchParamsInBrowserHistory', () => { + const originalWindowLocationHref = + 'http://localhost/?test_param=house&code=843cvg' + + beforeEach(() => { + window.history.replaceState({}, '', originalWindowLocationHref) + }) + + test('no args passed in', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + removeSearchParamsInBrowserHistory() + expect(window.location.href).toEqual(originalWindowLocationHref) + }) + + test('empty passed args', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + removeSearchParamsInBrowserHistory([]) + expect(window.location.href).toEqual(originalWindowLocationHref) + }) + + test('non-existing parameter in browser history', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + removeSearchParamsInBrowserHistory(['kode']) + expect(window.location.href).toEqual(originalWindowLocationHref) + }) + + test('remove existing parameter from browser history', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + removeSearchParamsInBrowserHistory(['test_param']) + expect(window.location.href).toEqual('http://localhost/?code=843cvg') + }) + + test('remove all existing parameters from browser history', () => { + expect(window.location.href).toEqual(originalWindowLocationHref) + removeSearchParamsInBrowserHistory(['test_param', 'code']) + expect(window.location.href).toEqual('http://localhost/?') + }) + + test('does not keep URL hash parameters', () => { + const hashUrlParams = '#box=/&%#/=Q4535&state_test=ljhf873' + window.history.replaceState( + {}, + '', + originalWindowLocationHref + hashUrlParams + ) + expect(window.location.href).toEqual( + originalWindowLocationHref + hashUrlParams + ) + removeSearchParamsInBrowserHistory(['test_param']) + expect(window.location.href).toEqual('http://localhost/?code=843cvg') + }) +}) diff --git a/src/shared/modules/auth/index.js b/src/shared/modules/auth/index.js new file mode 100644 index 00000000000..75270269b64 --- /dev/null +++ b/src/shared/modules/auth/index.js @@ -0,0 +1,270 @@ +import { + getCredentialsFromAuthResult, + getInitialisationParameters, + temporarilyStoreUrlSearchParams +} from './common' +import { + AUTH_STORAGE_CODE_VERIFIER, + AUTH_STORAGE_STATE, + BEARER, + IMPLICIT, + PKCE +} from './constants' +import { + createStateForRequest, + createNonce, + createCodeVerifier, + createCodeChallenge, + authLog, + authDebug, + removeSearchParamsInBrowserHistory +} from './helpers' +import { + defaultCodeChallengeMethod, + defaultGrantType, + searchParamsToRemoveAfterAuthRedirect +} from './settings' + +export const authRequestForSSO = async selectedSSOProvider => { + authLog('Initializing auth redirect request for SSO') + if (!selectedSSOProvider) { + throw new Error('Could not find SSO provider') + } + + if (!window.isSecureContext) { + throw new Error( + 'This application is NOT executed in a secure context. SSO support is therefore disabled. Load the application in a secure context to proceed with SSO.' + ) + } + + temporarilyStoreUrlSearchParams() + + const oauth2Endpoint = selectedSSOProvider.auth_endpoint + if (!oauth2Endpoint) { + throw new Error(`Invalid OAuth2 endpoint: "${oauth2Endpoint}"`) + } + authLog( + `Using OAuth2 endpoint: "${oauth2Endpoint}" for idp_id: ${selectedSSOProvider.id}` + ) + + const form = document.createElement('form') + form.setAttribute('method', 'GET') + form.setAttribute('action', oauth2Endpoint) + + const SSOParams = selectedSSOProvider.params || {} + const state = createStateForRequest() + let params = { + ...SSOParams, + state + } + window.sessionStorage.setItem(AUTH_STORAGE_STATE, state) + + const SSOExtraAuthParams = selectedSSOProvider.auth_params || {} + if (SSOExtraAuthParams) { + params = { + ...params, + ...SSOExtraAuthParams + } + } + + authLog( + `Using the following authorization parameter: ${JSON.stringify(SSOParams)}` + ) + + const SSOConfig = selectedSSOProvider.config || {} + if (SSOConfig.implicit_flow_requires_nonce) { + params = { + ...params, + nonce: createNonce() + } + authLog(`Using nonce in authorization request`) + } + + const _submitForm = (form, params) => { + for (const param in params) { + const input = document.createElement('input') + input.setAttribute('type', 'hidden') + input.setAttribute('name', param) + input.setAttribute('value', params[param]) + form.appendChild(input) + } + + document.body.appendChild(form) + form.submit() + } + + if (selectedSSOProvider.auth_flow === PKCE) { + const codeChallengeMethod = + SSOConfig.code_challenge_method || defaultCodeChallengeMethod + authLog( + `Auth flow "PKCE", using code_challenge_method: "${codeChallengeMethod}"` + ) + + try { + const codeVerifier = createCodeVerifier(codeChallengeMethod) + window.sessionStorage.setItem(AUTH_STORAGE_CODE_VERIFIER, codeVerifier) + + const codeChallenge = await createCodeChallenge( + codeChallengeMethod, + codeVerifier + ) + params = { + ...params, + code_challenge_method: codeChallengeMethod, + code_challenge: codeChallenge + } + _submitForm(form, params) + } catch (e) { + // caller handles the catching, adding rethrowing to make + // it clear we expect `createCodeVerifier` could throw + throw e + } + } else if (selectedSSOProvider.auth_flow === IMPLICIT) { + authLog('Auth flow "implicit flow"') + _submitForm(form, params) + } else { + throw new Error( + `Auth flow "${selectedSSOProvider.auth_flow}" is not supported.` + ) + } +} + +export const handleAuthFromRedirect = SSOProviders => + new Promise((resolve, reject) => { + const { + idp_id: idpId, + token_type: tokenType, + access_token: accessToken, + id_token: idToken, + error_description: errorDescription, + code, + state, + error + } = getInitialisationParameters() + + authLog('Handling auth rediret from SSO server') + + removeSearchParamsInBrowserHistory(searchParamsToRemoveAfterAuthRedirect) + + if (error) { + reject( + new Error( + `Error detected after auth redirect, aborting. Error: ${error}, Error description: ${errorDescription}` + ) + ) + return + } + + if (!idpId) { + reject(new Error('Invalid idp_id parameter, aborting')) + return + } + + const savedState = window.sessionStorage.getItem(AUTH_STORAGE_STATE) + if (state !== savedState) { + reject(new Error('Invalid state parameter, aborting')) + return + } + window.sessionStorage.setItem(AUTH_STORAGE_STATE, '') + + const selectedSSOProvider = SSOProviders.find(({ id }) => id === idpId) + if (!selectedSSOProvider) { + reject( + new Error( + `Couldn't find identity provider with id ${idpId}, only found ${SSOProviders.map( + provider => provider.id + ).join(', ')}` + ) + ) + } + + if ((tokenType || '').toLowerCase() === BEARER && accessToken) { + authLog('Successfully aquired access_token in "implicit flow"') + + authDebug('Implicit flow id_token', idToken) + authDebug('Implicit flow access_token', accessToken) + + try { + const credentials = getCredentialsFromAuthResult( + { access_token: accessToken, id_token: idToken }, + selectedSSOProvider + ) + resolve(credentials) + } catch (e) { + reject(new Error(`Failed to get credentials: ${e.message}`)) + } + } else { + authLog('Attempting to fetch token information in "PKCE flow"') + + authRequestForToken(selectedSSOProvider, code) + .then(res => res.json()) + .then(body => { + if (body && body.error) { + const errorType = body?.error || 'unknown' + const errorDesc = body['error_description'] || 'unknown' + const errorMsg = `Error detected after auth token request, aborting. Error: ${errorType}, Error description: ${errorDesc}` + reject(new Error(errorMsg)) + } else { + authLog('Successfully aquired token results') + authDebug('PKCE flow result', body) + + try { + const credentials = getCredentialsFromAuthResult( + body, + selectedSSOProvider + ) + resolve(credentials) + } catch (e) { + reject(new Error(`Failed to get credentials: ${e.message}`)) + } + } + }) + .catch(err => { + reject( + new Error( + `Aquiring token results for PKCE auth flow failed, err: ${err}` + ) + ) + }) + } + }) + +export const authRequestForToken = (selectedSSOProvider, code) => { + const SSOParams = selectedSSOProvider.params || {} + let details = { + grant_type: defaultGrantType, + client_id: SSOParams.client_id, + redirect_uri: SSOParams.redirect_uri, + code_verifier: window.sessionStorage.getItem(AUTH_STORAGE_CODE_VERIFIER), + code + } + window.sessionStorage.setItem(AUTH_STORAGE_CODE_VERIFIER, '') + + const SSOExtraTokenParams = selectedSSOProvider.token_params || {} + if (SSOExtraTokenParams) { + details = { + ...details, + ...SSOExtraTokenParams + } + } + + const requestOptions = { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + + let requestBody = [] + for (const property in details) { + const encodedKey = encodeURIComponent(property) + const encodedValue = encodeURIComponent(details[property]) + requestBody.push(encodedKey + '=' + encodedValue) + } + requestBody = requestBody.join('&') + + authLog(`Request for token in PKCE flow, idp_id: ${selectedSSOProvider.id}`) + const requestUrl = `${selectedSSOProvider.token_endpoint}?` + return window.fetch(requestUrl, { ...requestOptions, body: requestBody }) +} diff --git a/src/shared/modules/auth/settings.js b/src/shared/modules/auth/settings.js new file mode 100644 index 00000000000..767439d3987 --- /dev/null +++ b/src/shared/modules/auth/settings.js @@ -0,0 +1,31 @@ +export const mandatoryKeysForSSOProviders = [ + 'id', + 'name', + 'auth_flow', + 'params', + 'auth_endpoint', + 'well_known_discovery_uri' +] +export const mandatoryKeysForSSOProviderParams = [ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope' +] + +export const searchParamsToRemoveAfterAutoRedirect = ['cmd', 'arg'] +export const searchParamsToRemoveAfterAuthRedirect = [ + 'idp_id', + 'auth_flow_step', + 'state', + 'session_state', + 'code' +] + +export const defaultTokenTypePrincipal = 'access_token' +export const defaultTokenTypeAuthentication = 'access_token' +export const defaultGrantType = 'authorization_code' +export const defaultCodeChallengeMethod = 'S256' + +export const isAuthLoggingEnabled = true +export const isAuthDebuggingEnabled = process.env.NODE_ENV !== 'production' diff --git a/src/shared/modules/connections/connectionsDuck.test.ts b/src/shared/modules/connections/connectionsDuck.test.ts index 1832b37083f..33f441004d6 100644 --- a/src/shared/modules/connections/connectionsDuck.test.ts +++ b/src/shared/modules/connections/connectionsDuck.test.ts @@ -178,8 +178,12 @@ describe('connectionsDucks Epics', () => { expect(store.getActions()).toEqual([ action, connections.useDb(null), + updateDiscoveryConnection({ + SSOProviders: [], + SSOError: undefined + }), connections.setActiveConnection(null), - updateDiscoveryConnection({ password: '' }), + updateDiscoveryConnection({ password: '', SSOError: undefined }), currentAction ]) expect(bolt.openConnection).toHaveBeenCalledTimes(0) @@ -264,8 +268,12 @@ describe('startupConnectEpic', () => { expect(actions).toEqual([ action, connections.useDb(null), + updateDiscoveryConnection({ + SSOProviders: [], + SSOError: undefined + }), connections.setActiveConnection(null), - updateDiscoveryConnection({ password: '' }), + updateDiscoveryConnection({ password: '', SSOError: undefined }), currentAction ]) expect(bolt.openConnection).toHaveBeenCalledTimes(1) diff --git a/src/shared/modules/connections/connectionsDuck.ts b/src/shared/modules/connections/connectionsDuck.ts index f4c9d442fb3..993b21753f9 100644 --- a/src/shared/modules/connections/connectionsDuck.ts +++ b/src/shared/modules/connections/connectionsDuck.ts @@ -93,6 +93,7 @@ export type Connection = { authenticationMethod: AuthenticationMethod requestedUseDb?: string restApi?: string + SSOError?: string } const initialState: ConnectionReduxState = { @@ -433,17 +434,37 @@ export const verifyConnectionCredentialsEpic = (action$: any) => { }) } -type DiscoverDataAction = { - type: typeof discovery.DONE - discovered?: { - username?: string - requestedUseDb?: string - restApi?: string - supportsMultiDb?: string - host?: string - encrypted?: string - hasForceUrl?: boolean +export type DiscoverableData = { + username?: string + password?: string + requestedUseDb?: string + restApi?: string + supportsMultiDb?: boolean + host?: string + encrypted?: string + hasForceUrl?: boolean + SSOError?: string + attemptSSOLogin?: boolean + SSOProviders?: SSOProvider[] + neo4jVersion?: string +} +export type SSOProvider = { + id: string + name: string + auth_flow: string + params: { + client_id: string + redirect_uri: string + response_type: string + scope: string } + auth_endpoint: string + well_known_discovery_uri: string +} + +export type DiscoverDataAction = { + type: typeof discovery.DONE + discovered?: DiscoverableData } function shouldTryAutoconnecting(conn: Connection | null): boolean { @@ -466,6 +487,13 @@ export const startupConnectEpic = (action$: any, store: any) => { store.getState(), discovery.CONNECTION_ID ) + // always update SSO state providers + store.dispatch( + discovery.updateDiscoveryConnection({ + SSOProviders: discovered?.SSOProviders || [], + SSOError: discovered?.SSOError + }) + ) if ( !(discovered && discovered.hasForceUrl) && // If we have force url, don't try old connection data @@ -509,7 +537,10 @@ export const startupConnectEpic = (action$: any, store: any) => { store.dispatch( discovery.updateDiscoveryConnection({ username: '', - password: '' + password: '', + SSOError: discovered.attemptSSOLogin + ? 'SSO token was not accepted by neo4j' + : undefined }) ) resolve({ type: STARTUP_CONNECTION_FAILED }) @@ -520,7 +551,12 @@ export const startupConnectEpic = (action$: any, store: any) => { // Otherwise fail autoconnect store.dispatch(setActiveConnection(null)) - store.dispatch(discovery.updateDiscoveryConnection({ password: '' })) + store.dispatch( + discovery.updateDiscoveryConnection({ + password: '', + SSOError: discovered?.SSOError + }) + ) return Promise.resolve({ type: STARTUP_CONNECTION_FAILED }) }) } diff --git a/src/shared/modules/discovery/discoveryDuck.test.ts b/src/shared/modules/discovery/discoveryDuck.test.ts index 55c89fe5543..9e7721c0bbb 100644 --- a/src/shared/modules/discovery/discoveryDuck.test.ts +++ b/src/shared/modules/discovery/discoveryDuck.test.ts @@ -97,7 +97,12 @@ describe('discoveryOnStartupEpic', () => { action, { type: discovery.DONE, - discovered: { host: expectedHost } + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false + } } ]) done() @@ -118,7 +123,15 @@ describe('discoveryOnStartupEpic', () => { // Then expect(store.getActions()).toEqual([ action, - { type: discovery.DONE, discovered: { host: expectedHost } } + { + type: discovery.DONE, + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false + } + } ]) done() }) @@ -140,7 +153,12 @@ describe('discoveryOnStartupEpic', () => { action, { type: discovery.DONE, - discovered: { host: expectedHost } + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false + } } ]) done() @@ -166,7 +184,12 @@ describe('discoveryOnStartupEpic', () => { action, { type: discovery.DONE, - discovered: { host: expectedHost } + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false + } } ]) done() @@ -180,16 +203,22 @@ describe('discoveryOnStartupEpic', () => { // Given const action = { type: APP_START, - url: 'http://localhost/?connectURL=myhost:8888' + url: 'http://localhost/?connectURL=neo4j://myhost:8888' } - const expectedHost = 'myhost:8888' + const expectedHost = 'neo4j://myhost:8888' bus.take(discovery.DONE, () => { // Then expect(store.getActions()).toEqual([ action, { type: discovery.DONE, - discovered: { host: expectedHost, hasForceURL: true } + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false, + hasForceURL: true + } } ]) done() @@ -203,16 +232,22 @@ describe('discoveryOnStartupEpic', () => { // Given const action = { type: APP_START, - url: 'http://localhost/?dbms=myhost:8888' + url: 'http://localhost/?dbms=neo4j://myhost:8888' } - const expectedHost = 'myhost:8888' + const expectedHost = 'neo4j://myhost:8888' bus.take(discovery.DONE, () => { // Then expect(store.getActions()).toEqual([ action, { type: discovery.DONE, - discovered: { host: expectedHost, hasForceURL: true } + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false, + hasForceURL: true + } } ]) done() @@ -226,9 +261,9 @@ describe('discoveryOnStartupEpic', () => { // Given const action = { type: APP_START, - url: 'http://localhost/?dbms=myhost:8888&db=test' + url: 'http://localhost/?dbms=neo4j://myhost:8888&db=test' } - const expectedHost = 'myhost:8888' + const expectedHost = 'neo4j://myhost:8888' bus.take(discovery.DONE, () => { // Then expect(store.getActions()).toEqual([ @@ -239,7 +274,9 @@ describe('discoveryOnStartupEpic', () => { host: expectedHost, requestedUseDb: 'test', hasForceURL: true, - supportsMultiDb: true + supportsMultiDb: true, + SSOError: undefined, + SSOProviders: [] } } ]) @@ -256,7 +293,7 @@ describe('discoveryOnStartupEpic', () => { type: APP_START, url: 'http://localhost/?connectURL=bolt%2Brouting%3A%2F%2Fmyhost%3A8889' } - const expectedHost = 'bolt+routing://myhost:8889' + const expectedHost = 'neo4j://myhost:8889' bus.take(discovery.DONE, () => { // Then expect(store.getActions()).toEqual([ @@ -264,8 +301,11 @@ describe('discoveryOnStartupEpic', () => { { type: discovery.DONE, discovered: { - hasForceURL: true, - host: expectedHost + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false, + hasForceURL: true } } ]) @@ -283,7 +323,7 @@ describe('discoveryOnStartupEpic', () => { url: 'http://localhost/?connectURL=bolt%2Brouting%3A%2F%2Fneo4j%3Aneo4j%40myhost%3A8889' } - const expectedHost = 'bolt+routing://myhost:8889' + const expectedHost = 'neo4j://myhost:8889' bus.take(discovery.DONE, () => { // Then expect(store.getActions()).toEqual([ @@ -291,9 +331,12 @@ describe('discoveryOnStartupEpic', () => { { type: discovery.DONE, discovered: { - hasForceURL: true, + username: 'neo4j', host: expectedHost, - username: 'neo4j' + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false, + hasForceURL: true } } ]) @@ -338,7 +381,15 @@ describe('discoveryOnStartupEpic cloud env', () => { // Then expect(store.getActions()).toEqual([ action, - { type: discovery.DONE, discovered: { host: expectedHost } } + { + type: discovery.DONE, + discovered: { + host: expectedHost, + SSOError: undefined, + SSOProviders: [], + supportsMultiDb: false + } + } ]) done() }) diff --git a/src/shared/modules/discovery/discoveryDuck.ts b/src/shared/modules/discovery/discoveryDuck.ts index cfe366f74b8..ad6460e2561 100644 --- a/src/shared/modules/discovery/discoveryDuck.ts +++ b/src/shared/modules/discovery/discoveryDuck.ts @@ -18,9 +18,12 @@ * along with this program. If not, see . */ -import Rx from 'rxjs/Rx' import remote from 'services/remote' -import { updateConnection } from 'shared/modules/connections/connectionsDuck' +import { + DiscoverableData, + SSOProvider, + updateConnection +} from 'shared/modules/connections/connectionsDuck' import { APP_START, USER_CLEAR, @@ -30,12 +33,25 @@ import { CLOUD_SCHEMES } from 'shared/modules/app/appDuck' import { getDiscoveryEndpoint } from 'services/bolt/boltHelpers' -import { getUrlParamValue } from 'services/utils' import { generateBoltUrl } from 'services/boltscheme.utils' import { getUrlInfo } from 'shared/services/utils' import { isConnectedAuraHost } from 'shared/modules/connections/connectionsDuck' import { isCloudHost } from 'shared/services/utils' import { NEO4J_CLOUD_DOMAINS } from 'shared/modules/settings/settingsDuck' +import { + authRequestForSSO, + handleAuthFromRedirect +} from 'shared/modules/auth/index' +import { + authLog, + removeSearchParamsInBrowserHistory +} from 'shared/modules/auth/helpers' +import { + getSSOServerIdIfShouldRedirect, + getValidSSOProviders, + wasRedirectedBackFromSSOServer +} from 'shared/modules/auth/common' +import { searchParamsToRemoveAfterAutoRedirect } from 'shared/modules/auth/settings' export const NAME = 'discover-bolt-host' export const CONNECTION_ID = '$$discovery' @@ -133,23 +149,37 @@ export const discoveryOnStartupEpic = (some$: any, store: any) => { .ofType(APP_START) .map((action: any) => { if (!action.url) return action + const { searchParams } = new URL(action.url) + const passedURL = - getUrlParamValue('dbms', action.url) || - getUrlParamValue('connectURL', action.url) + searchParams.get('dbms') || searchParams.get('connectURL') + + const passedDb = searchParams.get('db') - const passedDb = getUrlParamValue('db', action.url) + if (passedURL) { + action.forceURL = decodeURIComponent(passedURL) + action.requestedUseDb = passedDb + } + + const discoveryURL = searchParams.get('discoveryURL') + + if (discoveryURL) { + action.discoveryURL = discoveryURL + } - if (!passedURL || !passedURL.length) return action - action.forceURL = decodeURIComponent(passedURL[0]) - action.requestedUseDb = passedDb && passedDb[0] return action }) .merge(some$.ofType(USER_CLEAR)) - .mergeMap((action: any) => { - // Only when in a environment were we can guess discovery endpoint - if (!hasDiscoveryEndpoint(store.getState())) { - return Promise.resolve({ type: 'NOOP' }) - } + .mergeMap(async (action: any) => { + // we can get data about which host different mechanisms, they are ranked in + // the following prioritization order + // 1. Url param - dbms + // 2. Url param - connectURL + // 3. database in discovery endpoint + // 4. Url param - discoveryURL + + let dataFromForceUrl: DiscoverableData = {} + if (action.forceURL) { const { username, protocol, host } = getUrlInfo(action.forceURL) @@ -166,48 +196,170 @@ export const discoveryOnStartupEpic = (some$: any, store: any) => { .filter(item => item[1] /* truthy check on value */) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) - return Promise.resolve({ - type: DONE, - discovered: onlyTruthy - }) + dataFromForceUrl = onlyTruthy } - return Rx.Observable.fromPromise( - remote - .getJSON(getDiscoveryEndpoint(getHostedUrl(store.getState()))) - // Uncomment below and comment out above when doing manual tests in dev mode to - // fake discovery response - //Promise.resolve({ - // bolt: 'bolt://localhost:7687', - //neo4j_version: '4.0.3' - //}) - .then(result => { - let host = - result && - (result.bolt_routing || result.bolt_direct || result.bolt) - // Try to get info from server - if (!host) { - throw new Error('No bolt address found') + + // Only do network call when we can guess discovery endpoint + if (!action.discoveryURL && !hasDiscoveryEndpoint(store.getState())) { + authLog('No discovery endpoint found or passed') + if (action.forceURL) { + return Promise.resolve({ type: DONE, discovered: dataFromForceUrl }) + } else { + return Promise.resolve({ type: 'NOOP' }) + } + } + + const discoveryEndpoint = getDiscoveryEndpoint( + getHostedUrl(store.getState()) + ) + const discoveryEndpointData = await fetchDataFromDiscoveryUrl( + discoveryEndpoint + ) + const discoveryURLData = await (action.discoveryURL + ? fetchDataFromDiscoveryUrl(action.discoveryURL) + : Promise.resolve({ SSOProviders: [] })) + + const newProvidersFromDiscoveryURL = discoveryURLData.SSOProviders.filter( + providerFromDiscUrl => + !discoveryEndpointData.SSOProviders.find( + provider => providerFromDiscUrl.id === provider.id + ) + ) + + const mergedSSOProviders = discoveryEndpointData.SSOProviders.concat( + newProvidersFromDiscoveryURL + ) + + const mergedDiscoveryData = { + ...discoveryURLData, + ...discoveryEndpointData, + ...dataFromForceUrl, + SSOProviders: mergedSSOProviders + } + + const isAura = isConnectedAuraHost(store.getState()) + mergedDiscoveryData.supportsMultiDb = + !!action.requestedUseDb || + (!isAura && + parseInt((mergedDiscoveryData.neo4j_version || '0').charAt(0)) >= 4) + + if (!mergedDiscoveryData.host) { + authLog('No host found in discovery data, aborting.') + return { type: DONE } + } + + mergedDiscoveryData.host = generateBoltUrl( + getAllowedBoltSchemesForHost( + store.getState(), + mergedDiscoveryData.host + ), + mergedDiscoveryData.host + ) + + let SSOError + const SSORedirectId = getSSOServerIdIfShouldRedirect() + if (SSORedirectId) { + authLog(`Initialised with idpId: "${SSORedirectId}"`) + + removeSearchParamsInBrowserHistory( + searchParamsToRemoveAfterAutoRedirect + ) + const selectedSSOProvider = mergedDiscoveryData.SSOProviders.find( + ({ id }) => id === SSORedirectId + ) + try { + await authRequestForSSO(selectedSSOProvider) + } catch (err) { + SSOError = err.message + authLog(err.message) + } + } else if (wasRedirectedBackFromSSOServer()) { + authLog('Handling auth_flow_step redirect') + + try { + const creds = (await handleAuthFromRedirect( + mergedDiscoveryData.SSOProviders + )) as { + username: string + password: string + } + + return { + type: DONE, + discovered: { + ...mergedDiscoveryData, + ...creds, + attemptSSOLogin: true } - host = generateBoltUrl( - getAllowedBoltSchemesForHost(store.getState(), host), - host - ) - - const isAura = isConnectedAuraHost(store.getState()) - const supportsMultiDb = - !isAura && parseInt((result.neo4j_version || '0').charAt(0)) >= 4 - const discovered = supportsMultiDb - ? { supportsMultiDb, host } - : { host } - - return { type: DONE, discovered } - }) - .catch(() => { - throw new Error('No info from endpoint') - }) - ).catch(() => { - return Promise.resolve({ type: DONE }) - }) + } + } catch (err) { + SSOError = err.message + authLog(err.message) + } + } + + return { type: DONE, discovered: { ...mergedDiscoveryData, SSOError } } }) .map((a: any) => a) } + +async function fetchDataFromDiscoveryUrl( + url: string +): Promise<{ + host?: string + neo4j_version?: string + SSOProviders: SSOProvider[] +}> { + try { + authLog(`Fetching ${url} to discover SSO providers`) + const result = await remote.getJSON(url) + // Uncomment below and comment out above when doing manual tests in dev mode to + // fake discovery response + //Promise.resolve({ + // bolt: 'bolt://localhost:7687', + // neo4j_version: '4.0.3' + //}) + const host = + result && (result.bolt_routing || result.bolt_direct || result.bolt) + + const ssoProviderField = + result.sso_providers || result.ssoproviders || result.ssoProviders + + if (!ssoProviderField) { + authLog(`No sso provider field found on json at ${url}`) + } + + const SSOProviders: SSOProvider[] = getValidSSOProviders(ssoProviderField) + if (SSOProviders.length === 0) { + authLog(`None of the sso providers found at ${url} were valid`) + } else { + authLog( + `Found SSO providers with ids:${SSOProviders.map(p => p.id).join( + ', ' + )} on ${url}` + ) + } + + return { SSOProviders, host } + } catch (e) { + const noDataFoundMessage = authLog(`No discovery json data found at ${url}`) + const noHttpPrefixMessage = url.toLowerCase().startsWith('http') + ? '' + : 'Double check that the url is a valid url (including HTTP(S)).' + const noJsonSuffixMessage = url.toLowerCase().endsWith('.json') + ? '' + : 'Double check that the discovery url returns a valid JSON file.' + ;[ + `Request to ${url} failed with message: ${e.message}`, + noDataFoundMessage, + noHttpPrefixMessage, + noJsonSuffixMessage + ] + .filter(a => a) + .forEach(err => { + authLog(err) + return err + }) + return { SSOProviders: [] } + } +} diff --git a/src/shared/services/commandInterpreterHelper.ts b/src/shared/services/commandInterpreterHelper.ts index 326cdc93080..3373f4314c9 100644 --- a/src/shared/services/commandInterpreterHelper.ts +++ b/src/shared/services/commandInterpreterHelper.ts @@ -104,6 +104,7 @@ import { import { unescapeCypherIdentifier } from './utils' import { getLatestFromFrameStack } from 'browser/modules/Stream/stream.utils' import { resolveGuide } from './guideResolverHelper' +import { AUTH_STORAGE_LOGS } from 'shared/modules/auth/constants' const PLAY_FRAME_TYPES = ['play', 'play-remote'] @@ -339,7 +340,11 @@ const availableCommands = [ const out = { userCapabilities: getUserCapabilities(store.getState()), serverConfig: getAvailableSettings(store.getState()), - browserSettings: getSettings(store.getState()) + browserSettings: getSettings(store.getState()), + ssoLogs: sessionStorage + .getItem(AUTH_STORAGE_LOGS) + ?.trim() + .split('\n') } put( frames.add({ diff --git a/tsconfig.json b/tsconfig.json index 12beb351fb0..25a16319d7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "noFallthroughCasesInSwitch": true, "jsx": "react", "outDir": "./build", - "allowJs": false, + "allowJs": true, "resolveJsonModule": true, "baseUrl": "./src", "paths": { diff --git a/yarn.lock b/yarn.lock index 981603296bc..0b5b434bfca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8326,6 +8326,11 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://neo.jfrog.io/neo/api/npm/npm/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha1-P7MZ82daLfDCiVyPXp+ktnsE7Vk= + keyboard-key@^1.0.4: version "1.1.0" resolved "https://neo.jfrog.io/neo/api/npm/npm/keyboard-key/-/keyboard-key-1.1.0.tgz#6f2e8e37fa11475bb1f1d65d5174f1b35653f5b7"
Constraints
ON ( book:Book ) ASSERT book.isbn IS UNIQUE