Skip to content

Commit

Permalink
fix(#1125): Fix scenario with OIDC and remote, authenticated Jolokia …
Browse files Browse the repository at this point in the history
…agent (#1126)
  • Loading branch information
grgrzybek committed Sep 26, 2024
1 parent e5532e1 commit aadc600
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 28 deletions.
5 changes: 5 additions & 0 deletions packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
33 changes: 27 additions & 6 deletions packages/hawtio/src/plugins/auth/oidc/oidc-service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -186,18 +191,28 @@ 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)
authorizationUrl.searchParams.set('code_challenge', code_challenge)
}
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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <HawtioPage>
// making client-redirects
sessionStorage.removeItem('connect-login-redirect')
setLoginFailed(false)
// Redirect to the original URL
connectService.redirect()
Expand Down
20 changes: 15 additions & 5 deletions packages/hawtio/src/plugins/shared/connect-service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<ConnectionTestResult>((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' }),
})
Expand Down Expand Up @@ -348,11 +350,18 @@ class ConnectService implements IConnectService {
const result = await new Promise<LoginResult>(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)
Expand All @@ -372,6 +381,7 @@ class ConnectService implements IConnectService {
}
resolve({ type: 'failure' })
},
headers,
},
)
})
Expand Down
49 changes: 33 additions & 16 deletions packages/hawtio/src/plugins/shared/jolokia-service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -329,25 +329,39 @@ class JolokiaService implements IJolokiaService {

private async configureAuthorization(options: RequestOptions): Promise<undefined> {
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<string, string>)['Authorization'] = `Bearer ${userService.getToken()}`
} else if (connection && connection.token) {
// TODO: when?
;(options.headers as Record<string, string>)['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 <base64(username:password)>"
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<string, string>)['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<string, string>)['X-XSRF-TOKEN'] = token
// } else {
// log.debug('Not set any authorization header')
}
}

Expand All @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion packages/hawtio/src/ui/page/HawtioPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <Navigate to={{ pathname: defaultPlugin.path, search }} /> : <HawtioHome />
let defaultPage = defaultPlugin ? <Navigate to={{ pathname: defaultPlugin.path, search }} /> : <HawtioHome />
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 = <Navigate to={{ pathname: tr, search }} />
}

const showVerticalNavByDefault = preferencesService.isShowVerticalNavByDefault()

Expand Down

0 comments on commit aadc600

Please sign in to comment.