Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(#1125): Fix scenario with OIDC and remote, authenticated Jolokia … #1126

Merged
merged 1 commit into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
grgrzybek marked this conversation as resolved.
Show resolved Hide resolved
}
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', '[email protected]')
// 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
Loading