From aadc600bbdad00190391e0d4f037537a27657f1e Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Thu, 26 Sep 2024 07:58:45 +0200 Subject: [PATCH] fix(#1125): Fix scenario with OIDC and remote, authenticated Jolokia agent (#1126) --- .../plugins/auth/keycloak/keycloak-service.ts | 5 ++ .../src/plugins/auth/oidc/oidc-service.ts | 33 ++++++++++--- .../plugins/connect/login/ConnectLogin.tsx | 3 ++ .../src/plugins/shared/connect-service.ts | 20 ++++++-- .../src/plugins/shared/jolokia-service.ts | 49 +++++++++++++------ packages/hawtio/src/ui/page/HawtioPage.tsx | 9 +++- 6 files changed, 91 insertions(+), 28 deletions(-) diff --git a/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts b/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts index 32204a23..0b451cb8 100644 --- a/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts +++ b/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts @@ -117,6 +117,11 @@ class KeycloakService implements IKeycloakService { const initOptions: KeycloakInitOptions = { onLoad: 'login-required', pkceMethod, + // required for Keycloak 23 + // see: https://github.com/keycloak/keycloak/issues/26651 + // and we can't switch to Keycloak 25 + // see: https://github.com/keycloak/keycloak/issues/27624 + useNonce: false, } return initOptions } diff --git a/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts b/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts index 608ff8ab..0900fb5a 100644 --- a/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts +++ b/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts @@ -1,5 +1,5 @@ import { ResolveUser, userService } from '@hawtiosrc/auth/user-service' -import { Logger } from '@hawtiosrc/core' +import { hawtio, Logger } from '@hawtiosrc/core' import { jwtDecode } from 'jwt-decode' import * as oidc from 'oauth4webapi' import { AuthorizationServer, Client, OAuth2Error } from 'oauth4webapi' @@ -163,6 +163,11 @@ export class OidcService implements IOidcService { // there are no query/fragment params in the URL, so we're logging for the first time const code_challenge_method = config!.code_challenge_method const code_verifier = oidc.generateRandomCodeVerifier() + // TODO: - this method calls crypto.subtle.digest('SHA-256', buf(codeVerifier)) so we need secure context + if (!window.isSecureContext) { + log.error("Can't perform OpenID Connect authentication in non-secure context") + return null + } const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier) const state = oidc.generateRandomState() @@ -186,6 +191,20 @@ export class OidcService implements IOidcService { authorizationUrl.searchParams.set('response_mode', config.response_mode) authorizationUrl.searchParams.set('client_id', config.client_id) authorizationUrl.searchParams.set('redirect_uri', config.redirect_uri) + const basePath = hawtio.getBasePath() + const u = new URL(window.location.href) + u.hash = '' + let redirect = u.pathname + if (basePath && redirect.startsWith(basePath)) { + redirect = redirect.slice(basePath.length) + if (redirect.startsWith('/')) { + redirect = redirect.slice(1) + } + } + // we have to use react-router to do client-redirect to connect/login if necessary + // and we can't do full redirect to URL that's not configured on OIDC provider + // and Entra ID can't use redirect_uri with wildcards... (Keycloak can do it) + sessionStorage.setItem('connect-login-redirect', redirect) authorizationUrl.searchParams.set('scope', config.scope) if (code_challenge_method) { authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) @@ -193,11 +212,7 @@ export class OidcService implements IOidcService { } authorizationUrl.searchParams.set('state', state) authorizationUrl.searchParams.set('nonce', nonce) - // authorizationUrl.searchParams.set('login_hint', 'hawtio-viewer@fuseqe.onmicrosoft.com') - // authorizationUrl.searchParams.set('hsu', '1') - if (config.prompt) { - authorizationUrl.searchParams.set('prompt', config.prompt) - } + // do not take 'prompt' option, leave the default non-set version, as it works best with Hawtio and redirects log.info('Redirecting to ', authorizationUrl) @@ -350,6 +365,7 @@ export class OidcService implements IOidcService { token_endpoint_auth_method: 'none', } + // use the original fetch - we don't want stack overflow const options: oidc.TokenEndpointRequestOptions = { [oidc.customFetch]: this.originalFetch } const res = await oidc.refreshTokenGrantRequest(as, client, userInfo.refresh_token, options).catch(e => { log.error('Problem refreshing token', e) @@ -383,6 +399,11 @@ export class OidcService implements IOidcService { } } + /** + * Replace global `fetch` function with a delegated call that handles authorization for remote Jolokia agents + * and target agent that may run as proxy (to remote Jolokia agent) + * @private + */ private async setupFetch() { let userInfo = await this.userInfo if (!userInfo) { diff --git a/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx b/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx index 281edb9e..644a4cdf 100644 --- a/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx +++ b/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx @@ -27,6 +27,9 @@ export const ConnectLogin: React.FunctionComponent = () => { const result = await connectService.login(username, password) switch (result.type) { case 'success': + // successful login at this page will finally stop + // making client-redirects + sessionStorage.removeItem('connect-login-redirect') setLoginFailed(false) // Redirect to the original URL connectService.redirect() diff --git a/packages/hawtio/src/plugins/shared/connect-service.ts b/packages/hawtio/src/plugins/shared/connect-service.ts index 4f3ab379..b7e50318 100644 --- a/packages/hawtio/src/plugins/shared/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/connect-service.ts @@ -1,10 +1,10 @@ import { eventService, hawtio } from '@hawtiosrc/core' import { decrypt, encrypt, generateKey, toBase64, toByteArray } from '@hawtiosrc/util/crypto' +import { basicAuthHeaderValue, getCookie } from '@hawtiosrc/util/https' import { toString } from '@hawtiosrc/util/strings' import { joinPaths } from '@hawtiosrc/util/urls' import Jolokia, { IJolokiaSimple } from '@jolokia.js/simple' import { log } from './globals' - export type Connections = { // key is ID, not name, so we can alter the name [key: string]: Connection @@ -21,9 +21,6 @@ export type Connection = { jolokiaUrl?: string username?: string password?: string - - // TODO: check if it is used - token?: string } export const INITIAL_CONNECTION: Connection = { @@ -264,11 +261,16 @@ class ConnectService implements IConnectService { // doesn't include "Basic" scheme. This is enough for the browser to skip the dialog. Even with xhr. return new Promise((resolve, reject) => { try { + const xsrfToken = getCookie('XSRF-TOKEN') + const headers: { [header: string]: string } = {} + if (xsrfToken) { + headers['X-XSRF-TOKEN'] = xsrfToken + } fetch(this.getJolokiaUrl(connection), { method: 'post', // with application/json, I'm getting "CanceledError: Request stream has been aborted" when running // via hawtioMiddleware... - headers: { 'Content-Type': 'text/json' }, + headers: { ...headers, 'Content-Type': 'text/json' }, credentials: 'same-origin', body: JSON.stringify({ type: 'version' }), }) @@ -348,11 +350,18 @@ class ConnectService implements IConnectService { const result = await new Promise(resolve => { connection.username = username connection.password = password + // this special header is used to pass credentials to remote Jolokia agent when + // Authorization header is already "taken" by OIDC/Keycloak authenticator + const headers = { + 'X-Jolokia-Authorization': basicAuthHeaderValue(connection.username, connection.password), + } this.createJolokia(connection, true).request( { type: 'version' }, { success: () => resolve({ type: 'success' }), + // this handles Jolokia error (HTTP status = 200, Jolokia status != 200) - unlikely for "version" request error: () => resolve({ type: 'failure' }), + // this handles HTTP status != 200 or other communication error (like connection refused) fetchError: (response: Response | null, error: DOMException | TypeError | string | null) => { if (response) { log.debug('Login error:', response.status, response.statusText) @@ -372,6 +381,7 @@ class ConnectService implements IConnectService { } resolve({ type: 'failure' }) }, + headers, }, ) }) diff --git a/packages/hawtio/src/plugins/shared/jolokia-service.ts b/packages/hawtio/src/plugins/shared/jolokia-service.ts index b74058f6..7d36c914 100644 --- a/packages/hawtio/src/plugins/shared/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/jolokia-service.ts @@ -1,6 +1,6 @@ import { userService } from '@hawtiosrc/auth' import { eventService, hawtio } from '@hawtiosrc/core' -import { getCookie } from '@hawtiosrc/util/https' +import { basicAuthHeaderValue, getCookie } from '@hawtiosrc/util/https' import { escapeMBeanPath, onAttributeSuccessAndError, @@ -329,25 +329,39 @@ class JolokiaService implements IJolokiaService { private async configureAuthorization(options: RequestOptions): Promise { const connection = await connectService.getCurrentConnection() - // Just set Authorization for now... + if (!options.headers) { + options.headers = {} + } + + // Set Authorization header depending on current setup + let authConfigured = false if ((await userService.isLogin()) && userService.getToken()) { log.debug('Set authorization header to token') ;(options.headers as Record)['Authorization'] = `Bearer ${userService.getToken()}` - } else if (connection && connection.token) { - // TODO: when? - ;(options.headers as Record)['Authorization'] = `Bearer ${connection.token}` - } else if (connection && connection.username && connection.password) { - log.debug('Set authorization header to username/password') - options.username = connection.username - options.password = connection.password + authConfigured = true } + + if (connection && connection.username && connection.password) { + if (!authConfigured) { + // we'll simply let Jolokia set the "Authorization: Basic " + log.debug('Set authorization header to username/password') + options.username = connection.username + options.password = connection.password + } else { + // we can't have two Authorization headers (one for proxy servlet and one for remote Jolokia agent), so + // we have to be smart here + ;(options.headers as Record)['X-Jolokia-Authorization'] = basicAuthHeaderValue( + connection.username, + connection.password, + ) + } + } + const token = getCookie('XSRF-TOKEN') if (token) { // For CSRF protection with Spring Security log.debug('Set XSRF token header from cookies') ;(options.headers as Record)['X-XSRF-TOKEN'] = token - // } else { - // log.debug('Not set any authorization header') } } @@ -364,12 +378,15 @@ class JolokiaService implements IJolokiaService { // If window was opened to connect to remote Jolokia endpoint if (url.searchParams.has(PARAM_KEY_CONNECTION) || sessionStorage.getItem(SESSION_KEY_CURRENT_CONNECTION)) { // we're in connected tab/window and Jolokia access attempt ended with 401/403 - // because xhr was used we _should_ have seen native browser popup to enter credentials and later - // to store them in browser's password manager. If user closes this dialog and doesn't enter any valid - // credentials we should display connect/login page with React dialog which accepts and stores the - // credentials in sessionStorage using encryption. - // but this is NOT possible in insecure context where we can't use window.crypto.subtle object + // if this 401 is delivered with 'WWW-Authenticate: Basic realm="xxx"' then native browser popup + // would appear to collect the credentials from user and store them (if user allows) in browser's + // password manager + // We've prevented this behaviour by translating 'WWW-Authenticate: Basic xxx' to + // 'WWW-Authenticate: Hawtio original-scheme="Basic" ...' + // this is how we are sure that React dialog is presented to collect the credentials and put them + // into session storage if (!window.isSecureContext) { + // but this is NOT possible in insecure context where we can't use window.crypto.subtle object // this won't work if user manually browses to URL with con=connection-id. // there will be "Scripts may not close windows that were not opened by script." warning in console window.close() diff --git a/packages/hawtio/src/ui/page/HawtioPage.tsx b/packages/hawtio/src/ui/page/HawtioPage.tsx index b7ba3dfe..fba9dc42 100644 --- a/packages/hawtio/src/ui/page/HawtioPage.tsx +++ b/packages/hawtio/src/ui/page/HawtioPage.tsx @@ -48,7 +48,14 @@ export const HawtioPage: React.FunctionComponent = () => { log.debug(`Login state: username = ${username}, isLogin = ${isLogin}`) const defaultPlugin = plugins[0] ?? null - const defaultPage = defaultPlugin ? : + let defaultPage = defaultPlugin ? : + const tr = sessionStorage.getItem('connect-login-redirect') + if (tr) { + // this is required for OIDC, because we can't have redirect_uri with + // wildcard on EntraID... + // this session storage item is removed after successful login at connect/login page + defaultPage = + } const showVerticalNavByDefault = preferencesService.isShowVerticalNavByDefault()