From a7611f2beec700c2fba392e8a88bb4af8af9f870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Thu, 26 Sep 2024 11:30:45 +0200 Subject: [PATCH] #1591 improve refresh of browser, re-using stored token from local storage / oidc-library --- ui/main.scss | 8 +- ui/modules/api.ts | 176 +++++++++++++-------- ui/modules/environments/authorization.html | 4 +- ui/modules/environments/authorization.ts | 64 ++++++-- ui/modules/environments/environments.ts | 10 +- ui/modules/utils.ts | 26 ++- 6 files changed, 198 insertions(+), 90 deletions(-) diff --git a/ui/main.scss b/ui/main.scss index 41a8aaec14..36193b8546 100644 --- a/ui/main.scss +++ b/ui/main.scss @@ -59,12 +59,18 @@ tbody { display: inline; } -.toast-header { +.toast-header-warn { color: #842029; background-color: #f8d7da; border-color: #f5c2c7; } +.toast-header-info { + color: #842029; + background-color: #ebd7f8; + border-color: #fbfbfb; +} + textarea { white-space: pre; overflow-wrap: normal; diff --git a/ui/modules/api.ts b/ui/modules/api.ts index 79116f0ca3..1a5dd2749a 100644 --- a/ui/modules/api.ts +++ b/ui/modules/api.ts @@ -16,6 +16,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill'; import * as Environments from './environments/environments.js'; import { AuthMethod } from './environments/environments.js'; import * as Utils from './utils.js'; +import { showError } from './utils.js'; const config = { @@ -280,18 +281,31 @@ let authHeaderValue; * @param {boolean} forDevOps if true, the credentials for the dev ops api will be used. */ export function setAuthHeader(forDevOps: boolean) { + authHeaderValue = undefined; let environment = Environments.current(); if (forDevOps) { let devopsAuthMethod = environment.authSettings?.devops?.method; if (devopsAuthMethod === AuthMethod.basic) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Basic ' + window.btoa(environment.authSettings.devops.basic.usernamePassword); + if (environment.authSettings.devops.basic.usernamePassword) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Basic ' + window.btoa(environment.authSettings.devops.basic.usernamePassword); + } else { + showError('DevOps Username/password missing') + } } else if (devopsAuthMethod === AuthMethod.bearer) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Bearer ' + environment.authSettings.devops.bearer.bearerToken; + if (environment.authSettings.devops.bearer.bearerToken) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Bearer ' + environment.authSettings.devops.bearer.bearerToken; + } else { + showError('DevOps Bearer token missing') + } } else if (devopsAuthMethod === AuthMethod.oidc) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Bearer ' + environment.authSettings.devops.oidc.bearerToken; + if (environment.authSettings.devops.oidc.bearerToken) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Bearer ' + environment.authSettings.devops.oidc.bearerToken; + } else { + showError('DevOps SSO (Bearer) token missing') + } } else { authHeaderKey = 'Authorization'; authHeaderValue = 'Basic'; @@ -299,17 +313,33 @@ export function setAuthHeader(forDevOps: boolean) { } else { let mainAuthMethod = environment.authSettings?.main?.method; if (mainAuthMethod === AuthMethod.basic) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Basic ' + window.btoa(environment.authSettings.main.basic.usernamePassword); + if (environment.authSettings.main.basic.usernamePassword) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Basic ' + window.btoa(environment.authSettings.main.basic.usernamePassword); + } else { + showError('Username/password missing') + } } else if (mainAuthMethod === AuthMethod.pre) { - authHeaderKey = 'x-ditto-pre-authenticated'; - authHeaderValue = environment.authSettings.main.pre.dittoPreAuthenticatedUsername; + if (environment.authSettings.main.pre.dittoPreAuthenticatedUsername) { + authHeaderKey = 'x-ditto-pre-authenticated'; + authHeaderValue = environment.authSettings.main.pre.dittoPreAuthenticatedUsername; + } else { + showError('Pre-Authenticated username missing') + } } else if (mainAuthMethod === AuthMethod.bearer) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Bearer ' + environment.authSettings.main.bearer.bearerToken; + if (environment.authSettings.main.bearer.bearerToken) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Bearer ' + environment.authSettings.main.bearer.bearerToken; + } else { + showError('Bearer token missing') + } } else if (mainAuthMethod === AuthMethod.oidc) { - authHeaderKey = 'Authorization'; - authHeaderValue = 'Bearer ' + environment.authSettings.main.oidc.bearerToken; + if (environment.authSettings.main.oidc.bearerToken) { + authHeaderKey = 'Authorization'; + authHeaderValue = 'Bearer ' + environment.authSettings.main.oidc.bearerToken; + } else { + showError('SSO (Bearer) token missing') + } } else { authHeaderKey = 'Authorization'; authHeaderValue = 'Basic'; @@ -351,62 +381,70 @@ export async function callDittoREST(method: string, returnHeaders = false, devOps = false, returnErrorJson = false): Promise { - let response; - const contentType = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'; - try { - response = await fetch(Environments.current().api_uri + (devOps ? '' : '/api/2') + path, { - method: method, - headers: { - 'Content-Type': contentType, - [authHeaderKey]: authHeaderValue, - ...additionalHeaders, - }, - ...(method !== 'GET' && method !== 'DELETE' && body !== undefined) && {body: JSON.stringify(body)}, - }); - } catch (err) { - Utils.showError(err); - throw err; - } - if (!response.ok) { - if (returnErrorJson) { + if (authHeaderValue) { + let response; + const contentType = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'; + try { + response = await fetch(Environments.current().api_uri + (devOps ? '' : '/api/2') + path, { + method: method, + headers: { + 'Content-Type': contentType, + [authHeaderKey]: authHeaderValue, + ...additionalHeaders, + }, + ...(method !== 'GET' && method !== 'DELETE' && body !== undefined) && {body: JSON.stringify(body)}, + }); + } catch (err) { + Utils.showError(err); + throw err; + } + if (!response.ok) { + if (returnErrorJson) { + if (returnHeaders) { + return response; + } else { + return response.json().then((dittoErr) => { + showDittoError(dittoErr, response); + return dittoErr; + }); + } + } else { + response.json() + .then((dittoErr) => { + showDittoError(dittoErr, response); + }) + .catch((err) => { + Utils.showError('No error details from Ditto', response.statusText, response.status); + }); + throw new Error('An error occurred: ' + response.status); + } + } + if (response.status !== 204) { if (returnHeaders) { return response; } else { - return response.json().then((dittoErr) => { - showDittoError(dittoErr, response); - return dittoErr; - }); + return response.json(); } } else { - response.json() - .then((dittoErr) => { - showDittoError(dittoErr, response); - }) - .catch((err) => { - Utils.showError('No error details from Ditto', response.statusText, response.status); - }); - throw new Error('An error occurred: ' + response.status); - } - } - if (response.status !== 204) { - if (returnHeaders) { - return response; - } else { - return response.json(); + return null; } } else { - return null; + throw new Error("Authentication missing"); } } export function getEventSource(thingIds, urlParams) { - return new EventSourcePolyfill( + if (authHeaderValue) { + return new EventSourcePolyfill( `${Environments.current().api_uri}/api/2/things?ids=${thingIds}${urlParams ? '&' + urlParams : ''}`, { headers: { [authHeaderKey]: authHeaderValue, }, }, - ); + ); + } else { + throw new Error("Authentication missing"); + } } /** @@ -433,19 +471,23 @@ export async function callConnectionsAPI(operation, successCallback, connectionI body = command; } - try { - response = await fetch(Environments.current().api_uri + params.path - .replace('{{connectionId}}', connectionId), { - method: params.method, - headers: { - 'Content-Type': operation === 'connectionCommand' ? 'text/plain' : 'application/json', - [authHeaderKey]: authHeaderValue, - }, - ...(body) && {body: body}, - }); - } catch (err) { - Utils.showError(err); - throw err; + if (authHeaderValue) { + try { + response = await fetch(Environments.current().api_uri + params.path + .replace('{{connectionId}}', connectionId), { + method: params.method, + headers: { + 'Content-Type': operation === 'connectionCommand' ? 'text/plain' : 'application/json', + [authHeaderKey]: authHeaderValue, + }, + ...(body) && {body: body}, + }); + } catch (err) { + Utils.showError(err); + throw err; + } + } else { + throw new Error("Authentication missing"); } if (!response.ok) { diff --git a/ui/modules/environments/authorization.html b/ui/modules/environments/authorization.html index 71dc15e816..60fa80d174 100644 --- a/ui/modules/environments/authorization.html +++ b/ui/modules/environments/authorization.html @@ -31,7 +31,7 @@
Main authentication
- + @@ -93,7 +93,7 @@
DevOps authentication
- + diff --git a/ui/modules/environments/authorization.ts b/ui/modules/environments/authorization.ts index 3c3a58772f..0ff973f4cc 100644 --- a/ui/modules/environments/authorization.ts +++ b/ui/modules/environments/authorization.ts @@ -14,11 +14,12 @@ import { UserManager, UserManagerSettings } from 'oidc-client-ts'; import * as API from '../api.js'; import * as Utils from '../utils.js'; +import { showError, showInfoToast } from '../utils.js'; import authorizationHTML from './authorization.html'; /* eslint-disable prefer-const */ /* eslint-disable require-jsdoc */ import * as Environments from './environments.js'; -import { AuthMethod, OidcAuthSettings, URL_OIDC_PROVIDER } from './environments.js'; +import { AuthMethod, OidcAuthSettings, URL_OIDC_PROVIDER, URL_PRIMARY_ENVIRONMENT_NAME } from './environments.js'; let dom = { mainBearerSection: null, @@ -107,26 +108,36 @@ export function ready() { e.preventDefault(); let environment = Environments.current(); environment.authSettings.main.oidc.provider = dom.oidcProvider.value; - await performSingleSignOn(environment.authSettings.main.oidc) + let alreadyLoggedIn = await performSingleSignOn(environment.authSettings.main.oidc) + if (alreadyLoggedIn) { + showInfoToast('You are already logged in') + } + await Environments.environmentsJsonChanged(false); }; document.getElementById('main-oidc-logout').onclick = async (e) => { e.preventDefault(); let environment = Environments.current(); environment.authSettings.main.oidc.provider = dom.oidcProvider.value; await performSingleSignOut(environment.authSettings.main.oidc) + await Environments.environmentsJsonChanged(false); }; document.getElementById('devops-oidc-login').onclick = async (e) => { e.preventDefault(); let environment = Environments.current(); environment.authSettings.devops.oidc.provider = dom.devOpsOidcProvider.value; - await performSingleSignOn(environment.authSettings.devops.oidc) + let alreadyLoggedIn = await performSingleSignOn(environment.authSettings.devops.oidc) + if (alreadyLoggedIn) { + showInfoToast('You are already logged in') + } + await Environments.environmentsJsonChanged(false); }; document.getElementById('devops-oidc-logout').onclick = async (e) => { e.preventDefault(); let environment = Environments.current(); environment.authSettings.devops.oidc.provider = dom.devOpsOidcProvider.value; await performSingleSignOut(environment.authSettings.devops.oidc) + await Environments.environmentsJsonChanged(false); }; } @@ -146,15 +157,18 @@ async function handleSingleSignOnCallback(oidc: OidcAuthSettings) { const userManager = new UserManager(settings); try { let user = await userManager.signinCallback(window.location.href) - oidc.bearerToken = user.access_token - window.history.replaceState(null, null, `${settings.redirect_uri}?${user.url_state}`) + if (user) { + oidc.bearerToken = user.access_token + window.history.replaceState(null, null, `${settings.redirect_uri}?${user.url_state}`) + await Environments.environmentsJsonChanged(false) + } } catch (e) { console.error(`Could not login due to: ${e}`) } } } -async function performSingleSignOn(oidc: OidcAuthSettings) { +async function performSingleSignOn(oidc: OidcAuthSettings): Promise { let environment = Environments.current(); const settings: UserManagerSettings = environment.authSettings.oidc.providers[oidc.provider]; if (settings !== undefined && settings !== null) { @@ -162,25 +176,42 @@ async function performSingleSignOn(oidc: OidcAuthSettings) { const userManager = new UserManager(settings); if (isSsoCallbackRequest(urlSearchParams)) { await handleSingleSignOnCallback(oidc) + return false } else { - urlSearchParams.set(URL_OIDC_PROVIDER, oidc.provider) - await userManager.signinRedirect({ - url_state: urlSearchParams.toString() - }); + let user = await userManager.getUser(); + if (user?.access_token !== undefined || user?.expired === true) { + // a user is still logged in via a valid token stored in the browser's session storage + oidc.bearerToken = user?.access_token + return true + } else { + urlSearchParams.set(URL_PRIMARY_ENVIRONMENT_NAME, Environments.currentEnvironmentSelector()) + urlSearchParams.set(URL_OIDC_PROVIDER, oidc.provider) + try { + await userManager.signinRedirect({ + url_state: urlSearchParams.toString() + }); + } catch (e) { + showError(e) + return false + } + } } } } async function performSingleSignOut(oidc: OidcAuthSettings) { - const urlSearchParams: URLSearchParams = new URLSearchParams(window.location.search); let environment = Environments.current(); const settings: UserManagerSettings = environment.authSettings.oidc.providers[oidc.provider]; if (settings !== undefined && settings !== null) { const userManager = new UserManager(settings); - urlSearchParams.set(URL_OIDC_PROVIDER, oidc.provider) - await userManager.signoutRedirect({ - state: urlSearchParams.toString() - }) + try { + await userManager.signoutRedirect() + } catch (e) { + showError(e) + } finally { + oidc.bearerToken = undefined + await Environments.environmentsJsonChanged(false) + } } } @@ -238,8 +269,9 @@ export async function onEnvironmentChanged(initialPageLoad: boolean) { environment.authSettings?.main?.oidc?.autoSso === true ) { await performSingleSignOn(environment.authSettings?.main?.oidc); + await Environments.environmentsJsonChanged(false); } else if (isSsoCallbackRequest()) { - await handleSingleSignOnCallback(environment.authSettings?.main?.oidc) + await handleSingleSignOnCallback(environment.authSettings?.main?.oidc); } API.setAuthHeader(_forDevops); diff --git a/ui/modules/environments/environments.ts b/ui/modules/environments/environments.ts index 16ac8f1b5c..237fd784d8 100644 --- a/ui/modules/environments/environments.ts +++ b/ui/modules/environments/environments.ts @@ -21,7 +21,7 @@ import environmentsHTML from './environments.html'; import defaultTemplates from './environmentTemplates.json'; const OIDC_CALLBACK_STATE = 'state'; -const URL_PRIMARY_ENVIRONMENT_NAME = 'primaryEnvironmentName'; +export const URL_PRIMARY_ENVIRONMENT_NAME = 'primaryEnvironmentName'; export const URL_OIDC_PROVIDER = 'oidcProvider'; const URL_ENVIRONMENTS = 'environmentsURL'; const STORAGE_KEY = 'ditto-ui-env'; @@ -133,8 +133,12 @@ function Environment(env: Environment): void { this.authSettings.devops.basic.usernamePassword = env.authSettings?.devops?.basic?.defaultUsernamePassword } +export function currentEnvironmentSelector() { + return dom.environmentSelector.value; +} + export function current() { - return environments[dom.environmentSelector.value]; + return environments[currentEnvironmentSelector()]; } export function addChangeListener(observer) { @@ -181,7 +185,7 @@ export async function ready() { } async function onEnvironmentSelectorChange() { - urlSearchParams.set(URL_PRIMARY_ENVIRONMENT_NAME, dom.environmentSelector.value); + urlSearchParams.set(URL_PRIMARY_ENVIRONMENT_NAME, currentEnvironmentSelector()); window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams}`); await notifyAll(false); } diff --git a/ui/modules/utils.ts b/ui/modules/utils.ts index 0fde531fb7..7eac1018f1 100644 --- a/ui/modules/utils.ts +++ b/ui/modules/utils.ts @@ -236,6 +236,30 @@ export function sanitizeHTML(unsafeString: string) { return DOMPurify.sanitize(unsafeString); } +/** + * Show an info toast + * @param {string} message Message for toast + * @param {string} header Header for toast + */ +export function showInfoToast(message: string, header: string = 'Info') { + const domToast = document.createElement('div'); + domToast.classList.add('toast'); + domToast.innerHTML = `
+ + ${sanitizeHTML(header)} + ${status} + +
+
${sanitizeHTML(message)}
`; + + dom.toastContainer.appendChild(domToast); + domToast.addEventListener("hidden.bs.toast", () => { + domToast.remove(); + }); + const bsToast = new Toast(domToast); + bsToast.show(); +} + /** * Show an error toast * @param {string} message Message for toast @@ -245,7 +269,7 @@ export function sanitizeHTML(unsafeString: string) { export function showError(message: string, header: string = 'Error', status: string = '') { const domToast = document.createElement('div'); domToast.classList.add('toast'); - domToast.innerHTML = `
+ domToast.innerHTML = `
${sanitizeHTML(header)} ${status}