From c6e10f913fae5a30294c8803e77497fde18c8cc8 Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Thu, 20 Feb 2025 19:55:29 +0100 Subject: [PATCH 1/7] Add possibility to use secured services by adding interceptorUrlRegex All urls that fit the regular expression defined by interceptorUrlRegex have a token set in the cookies of the browser set in the Authorization header as a Bearer token for all outgoing requests. --- packages/core/CHANGELOG.md | 4 +++ packages/core/README.md | 3 +- .../src/utils/createMap/addInterceptor.ts | 29 +++++++++++++++++++ packages/core/src/utils/createMap/index.ts | 4 +++ packages/core/src/utils/getCookie.ts | 11 +++++++ packages/types/custom/CHANGELOG.md | 1 + packages/types/custom/core.ts | 1 + 7 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/utils/createMap/addInterceptor.ts create mode 100644 packages/core/src/utils/getCookie.ts diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index aa6ba2a52..7745eebba 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## unpublished + +- Feature: Add possibility of using OIDC secured services by using the configuration parameter `interceptorUrlRegex`. + ## 3.0.0 - Breaking: Upgrade `@masterportal/masterportalapi` from `2.40.0` to `2.45.0` and subsequently `ol` from `^9.2.4` to `^10.3.1`. diff --git a/packages/core/README.md b/packages/core/README.md index b8e790ae3..ca2d3114c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -91,6 +91,7 @@ The mapConfiguration allows controlling many client instance details. | checkServiceAvailability | boolean? | If set to `true`, all services' availability will be checked with head requests. | | extendedMasterportalapiMarkers | extendedMasterportalapiMarkers? | Optional. If set, all configured visible vector layers' features can be hovered and selected by mouseover and click respectively. They are available as features in the store. Layers with `clusterDistance` will be clustered to a multi-marker that supports the same features. Please mind that this only works properly if you configure nothing but point marker vector layers styled by the masterportalapi. | | featureStyles | string? | Optional path to define styles for vector features. See `mapConfiguration.featureStyles` for more information. May be a url or a path on the local file system. | +| interceptorUrlRegex | string? | Optional regular expression defining URLs that belong to secured services. All requests sent to URLs that fit the regular expression will send the JSON Web Token (JWT) found as a cookie named `'token'` as a Bearer token in the Authorization header of the request. | | language | enum["de", "en"]? | Initial language. | | locales | Locale[]? | All locales in POLAR's plugins can be overridden to fit your needs.| | | various? | Fields for configuring plugins added with `addPlugins`. Refer to each plugin's documentation for specific fields and options. Global plugin parameters are described [below](#global-plugin-parameters). | @@ -557,4 +558,4 @@ You may desire to listen to whether the loader is currently being shown. | map | Map \| null | Returns the openlayers map object. | | hovered | Feature \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the currently hovered marker. Please mind that it may be a clustered feature. | | selected | Feature \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the currently selected marker. Please mind that it may be a clustered feature. | -| selectedCoordinates | Array \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the coordinates of the currently selected marker. | \ No newline at end of file +| selectedCoordinates | Array \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the coordinates of the currently selected marker. | diff --git a/packages/core/src/utils/createMap/addInterceptor.ts b/packages/core/src/utils/createMap/addInterceptor.ts new file mode 100644 index 000000000..7084686ca --- /dev/null +++ b/packages/core/src/utils/createMap/addInterceptor.ts @@ -0,0 +1,29 @@ +import { getCookie } from '../getCookie' + +/** + * Add an interceptor to fetch to add the token saved in the cookies to the + * request headers. If interceptors for XMLHttpRequest or axios are needed, + * add them here. + * Function is based on functionality from + * https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsAxios.js + * + * @param interceptorUrlRegex - URLs fitting this regular expression have the token added. + */ +export function addInterceptor(interceptorUrlRegex: string) { + const { fetch: originalFetch } = window + + window.fetch = (resource, originalConfig) => { + let config = originalConfig + + // @ts-expect-error | Has worked like charm so far. If an error occurs if resource is of type RequestInfo, take another look + if (interceptorUrlRegex && resource?.match(interceptorUrlRegex)) { + config = { + ...originalConfig, + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { Authorization: `Bearer ${getCookie('token')}` }, + } + } + + return originalFetch(resource, config) + } +} diff --git a/packages/core/src/utils/createMap/index.ts b/packages/core/src/utils/createMap/index.ts index 478b34494..6b649fa28 100644 --- a/packages/core/src/utils/createMap/index.ts +++ b/packages/core/src/utils/createMap/index.ts @@ -11,6 +11,7 @@ import { makeShadowRoot } from './makeShadowRoot' import { pullPolarStyleToShadow } from './pullPolarStyleToShadow' import { pullVuetifyStyleToShadow } from './pullVuetifyStyleToShadow' import { setupFontawesome } from './setupFontawesome' +import { addInterceptor } from './addInterceptor' /** * createMap handles plugging all the parts together to create a configured map. @@ -51,6 +52,9 @@ export default async function createMap({ setupFontawesome(shadowRoot, mapConfiguration.renderFaToLightDom) updateSizeOnReady(instance) + if (mapConfiguration.interceptorUrlRegex) { + addInterceptor(mapConfiguration.interceptorUrlRegex) + } // Restore theme ID such that external Vuetify app can find it again if (externalStylesheet) { externalStylesheet.id = 'vuetify-theme-stylesheet' diff --git a/packages/core/src/utils/getCookie.ts b/packages/core/src/utils/getCookie.ts new file mode 100644 index 000000000..ce123ff49 --- /dev/null +++ b/packages/core/src/utils/getCookie.ts @@ -0,0 +1,11 @@ +/** + * @param name - Name of the cookie. + * @returns Returns the value of a cookie with the given name or undefined. + */ +export function getCookie(name: string) { + const cookie = document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith(name)) + return cookie ? cookie.substring(name.length + 1) : undefined +} diff --git a/packages/types/custom/CHANGELOG.md b/packages/types/custom/CHANGELOG.md index e2bc30d1a..64f80f972 100644 --- a/packages/types/custom/CHANGELOG.md +++ b/packages/types/custom/CHANGELOG.md @@ -3,6 +3,7 @@ ## unpublished - Feature: Add `snapTo` to `DrawConfiguration` for specification of layers to snap to. +- Feature: Add new configuration parameter `interceptorUrlRegex` to `MapConfig`. ## 2.0.0 diff --git a/packages/types/custom/core.ts b/packages/types/custom/core.ts index 3492c36e9..c964f958f 100644 --- a/packages/types/custom/core.ts +++ b/packages/types/custom/core.ts @@ -653,6 +653,7 @@ export interface MapConfig extends MasterportalApiConfig { checkServiceAvailability?: boolean extendedMasterportalapiMarkers?: ExtendedMasterportalapiMarkers featureStyles?: string + interceptorUrlRegex?: string language?: InitialLanguage locales?: Locale[] renderFaToLightDom?: boolean From 46eecc8eea4aa829cd9da6ceffa35d9918f324c7 Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Mon, 24 Feb 2025 15:37:47 +0100 Subject: [PATCH 2/7] Add authentication example to @polar/client-diplan URLs to services and authentication server are omitted. --- .../diplan/example/authentication/cookie.js | 70 ++++++++++++++++++ .../diplan/example/authentication/index.js | 73 +++++++++++++++++++ .../diplan/example/authentication/parseJWT.js | 35 +++++++++ .../example/authentication/validateForm.js | 15 ++++ packages/clients/diplan/example/config.js | 7 ++ packages/clients/diplan/example/index.html | 16 ++++ packages/clients/diplan/example/services.js | 8 ++ packages/clients/diplan/example/setup.js | 5 ++ 8 files changed, 229 insertions(+) create mode 100644 packages/clients/diplan/example/authentication/cookie.js create mode 100644 packages/clients/diplan/example/authentication/index.js create mode 100644 packages/clients/diplan/example/authentication/parseJWT.js create mode 100644 packages/clients/diplan/example/authentication/validateForm.js diff --git a/packages/clients/diplan/example/authentication/cookie.js b/packages/clients/diplan/example/authentication/cookie.js new file mode 100644 index 000000000..6db3a0c52 --- /dev/null +++ b/packages/clients/diplan/example/authentication/cookie.js @@ -0,0 +1,70 @@ +import { parseJWT } from './parseJWT' + +/* + * Functions are based on https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsCookies.js + * and https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsOIDC.js. + */ + +/** + * Set a cookie value. + * + * @param {string} name of cookie to set. + * @param {string} value to set into a cookie. + */ +export function setCookie(name, value) { + const date = new Date() + // Cookie is valid for 15 minutes + date.setTime(date.getTime() + 15 * 1000) + document.cookie = `${name}=${ + value || '' + }; expires=${date.toUTCString()}; secure; path=/` +} + +/** + * Gets value of a cookie. + * + * @param {string} name of cookie to retrieve. + * @returns {string} cookie value. + */ +export function getCookie(name) { + const nameEQ = `${name}=` + const ca = document.cookie.split(';') + + for (let i = 0; i < ca.length; i++) { + let c = ca[i] + + while (c.charAt(0) === ' ') { + c = c.substring(1, c.length) + } + if (c.indexOf(nameEQ) === 0) { + return c.substring(nameEQ.length, c.length) + } + } + return undefined +} + +export function setCookies(token, expiresIn, refreshToken) { + setCookie('token', token) + setCookie('expires_in', expiresIn) + setCookie('refresh_token', refreshToken) + + const account = parseJWT(token) + + setCookie('name', account?.name) + setCookie('email', account?.email) + setCookie('username', account?.preferred_username) + + setCookie('expiry', account?.exp) +} + +/** + * Delete all cookies with names given in list + * + * @param {String[]} names of cookies to delete + * @return {void} + */ +export function eraseCookies(names) { + names.forEach((name) => { + document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;` + }) +} diff --git a/packages/clients/diplan/example/authentication/index.js b/packages/clients/diplan/example/authentication/index.js new file mode 100644 index 000000000..4e3be0293 --- /dev/null +++ b/packages/clients/diplan/example/authentication/index.js @@ -0,0 +1,73 @@ +import { eraseCookies, getCookie, setCookies } from './cookie' + +const clientId = 'polar' +const scope = 'openid' + +let loggedIn = false + +export async function authenticate(username, password) { + if (loggedIn) { + await reset() + return + } + + const url = '' + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: `grant_type=password&client_id=${encodeURIComponent( + clientId + )}&username=${encodeURIComponent(username)}&password=${encodeURIComponent( + password + )}&scope=${encodeURIComponent(scope)}`, + }) + if (response.ok) { + const data = await response.json() + setCookies(data.access_token, data.expires_in, data.refresh_token) + document.getElementById('login-button').textContent = 'Logout' + document.getElementById('username').disabled = true + document.getElementById('password').disabled = true + loggedIn = true + return + } + document.getElementById('error-message').textContent = + 'Die angegebene Kombination aus Nutzername und Passwort ist nicht korrekt.' + // TODO: Add UI element to a layer if it can only be used via authentication (lock / unlock) +} + +async function reset() { + await revokeToken(getCookie('token')) + await revokeToken(getCookie('refresh_token')) + eraseCookies([ + 'token', + 'expires_in', + 'refresh_token', + 'name', + 'username', + 'email', + 'expiry', + ]) + window.location.reload() +} + +async function revokeToken(token) { + const url = '' + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: `grant_type=refresh_token&token=${encodeURIComponent( + token + )}&client_id=${encodeURIComponent(clientId)}`, + }) + if (response.ok) { + return + } + document.getElementById('error-message').textContent = + 'Der Nutzer konnte nicht abgemeldet werden.' +} diff --git a/packages/clients/diplan/example/authentication/parseJWT.js b/packages/clients/diplan/example/authentication/parseJWT.js new file mode 100644 index 000000000..470921d4d --- /dev/null +++ b/packages/clients/diplan/example/authentication/parseJWT.js @@ -0,0 +1,35 @@ +/** + * Parses jwt token. This function does *not* validate the token. + * Function is based on https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsOIDC.js. + * + * @param {string} token jwt token to be parsed. + * @returns {object} parsed jwt token as object + */ +export function parseJWT(token) { + try { + if (!token) { + return {} + } + + const base64Url = token.split('.')[1] + if (!base64Url) { + return {} + } + + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + return JSON.parse( + decodeURIComponent( + window + .atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join('') + ) + ) + } catch (e) { + // If token is not valid or another error occurs, return an empty object + return {} + } +} diff --git a/packages/clients/diplan/example/authentication/validateForm.js b/packages/clients/diplan/example/authentication/validateForm.js new file mode 100644 index 000000000..8455fa741 --- /dev/null +++ b/packages/clients/diplan/example/authentication/validateForm.js @@ -0,0 +1,15 @@ +import { authenticate } from './index.js' + +export function validateForm(interceptorUrlRegex) { + const username = document.getElementById('username').value + const password = document.getElementById('password').value + const errorMessage = document.getElementById('error-message') + + if (!username || !password) { + errorMessage.textContent = + 'Bitte geben Sie sowohl einen Nutzernamen als auch ein Passwort ein.' + } else { + errorMessage.textContent = '' + authenticate(username, password, interceptorUrlRegex) + } +} diff --git a/packages/clients/diplan/example/config.js b/packages/clients/diplan/example/config.js index 300dd7c3f..2b61c0d8d 100644 --- a/packages/clients/diplan/example/config.js +++ b/packages/clients/diplan/example/config.js @@ -51,6 +51,7 @@ export default { minLength: 3, waitMs: 300, }, + interceptorUrlRegex: '', layers: [ { id: basemap, @@ -84,6 +85,12 @@ export default { type: 'mask', name: `diplan.layers.${bstgasleitung}`, }, + { + id: 'secureServiceTest', + visibility: false, + type: 'mask', + name: 'Secure Service Test', + }, ], attributions: { layerAttributions: [ diff --git a/packages/clients/diplan/example/index.html b/packages/clients/diplan/example/index.html index 8c6202313..50478fb22 100644 --- a/packages/clients/diplan/example/index.html +++ b/packages/clients/diplan/example/index.html @@ -15,6 +15,22 @@

Testseite @polar/client-diplan (dev)

Beispielinstanz

+
+

Loginbeispiel

+
+ + +
+ +
+

+

Steuerung von außen

diff --git a/packages/clients/diplan/example/services.js b/packages/clients/diplan/example/services.js index b970b44a3..567d29271 100644 --- a/packages/clients/diplan/example/services.js +++ b/packages/clients/diplan/example/services.js @@ -460,4 +460,12 @@ export default [ crs: 'http://www.opengis.net/def/crs/EPSG/0/25832', bboxCrs: 'http://www.opengis.net/def/crs/EPSG/0/25832', }, + { + id: 'secureServiceTest', + url: '', + typ: 'WMS', + layers: '', + format: 'image/png', + version: '1.3.0', + }, ] diff --git a/packages/clients/diplan/example/setup.js b/packages/clients/diplan/example/setup.js index 757f19095..d2c260556 100644 --- a/packages/clients/diplan/example/setup.js +++ b/packages/clients/diplan/example/setup.js @@ -1,5 +1,7 @@ /* eslint-disable max-lines-per-function */ +import { validateForm } from './authentication/validateForm' + const geoJSON = { type: 'FeatureCollection', features: [ @@ -60,6 +62,9 @@ export default (client, layerConf, config) => { * https://dataport.github.io/polar/docs/diplan/client-diplan.html */ + const loginButton = document.getElementById('login-button') + loginButton.onclick = () => validateForm() + // TODO expand example bindings const actionPlus = document.getElementById('action-plus') From 26d14e29d37e19993dde33831f808fdcfb112499 Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Tue, 25 Feb 2025 12:16:53 +0100 Subject: [PATCH 3/7] Add typeof check for strings and remove ts-expect-error --- packages/core/src/utils/createMap/addInterceptor.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/createMap/addInterceptor.ts b/packages/core/src/utils/createMap/addInterceptor.ts index 7084686ca..83b6cf4e4 100644 --- a/packages/core/src/utils/createMap/addInterceptor.ts +++ b/packages/core/src/utils/createMap/addInterceptor.ts @@ -15,8 +15,11 @@ export function addInterceptor(interceptorUrlRegex: string) { window.fetch = (resource, originalConfig) => { let config = originalConfig - // @ts-expect-error | Has worked like charm so far. If an error occurs if resource is of type RequestInfo, take another look - if (interceptorUrlRegex && resource?.match(interceptorUrlRegex)) { + if ( + interceptorUrlRegex && + typeof resource === 'string' && + resource?.match(interceptorUrlRegex) + ) { config = { ...originalConfig, // eslint-disable-next-line @typescript-eslint/naming-convention From 4a99366115a333b1ff34875164b943a92bc9f0db Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Tue, 25 Feb 2025 12:27:28 +0100 Subject: [PATCH 4/7] Add prod example for authentication and add missing file endings --- .../diplan/example/authentication/cookie.js | 2 +- .../diplan/example/authentication/index.js | 2 +- .../clients/diplan/example/prod-example.html | 16 ++++++++++++++++ packages/clients/diplan/example/setup.js | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/clients/diplan/example/authentication/cookie.js b/packages/clients/diplan/example/authentication/cookie.js index 6db3a0c52..01f742ea1 100644 --- a/packages/clients/diplan/example/authentication/cookie.js +++ b/packages/clients/diplan/example/authentication/cookie.js @@ -1,4 +1,4 @@ -import { parseJWT } from './parseJWT' +import { parseJWT } from './parseJWT.js' /* * Functions are based on https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsCookies.js diff --git a/packages/clients/diplan/example/authentication/index.js b/packages/clients/diplan/example/authentication/index.js index 4e3be0293..daf1e79b5 100644 --- a/packages/clients/diplan/example/authentication/index.js +++ b/packages/clients/diplan/example/authentication/index.js @@ -1,4 +1,4 @@ -import { eraseCookies, getCookie, setCookies } from './cookie' +import { eraseCookies, getCookie, setCookies } from './cookie.js' const clientId = 'polar' const scope = 'openid' diff --git a/packages/clients/diplan/example/prod-example.html b/packages/clients/diplan/example/prod-example.html index c552c6776..1111873d3 100644 --- a/packages/clients/diplan/example/prod-example.html +++ b/packages/clients/diplan/example/prod-example.html @@ -15,6 +15,22 @@

Testseite @polar/client-diplan (build)

Beispielinstanz

+
+

Loginbeispiel

+
+ + +
+ +
+

+

Steuerung von außen

diff --git a/packages/clients/diplan/example/setup.js b/packages/clients/diplan/example/setup.js index d2c260556..a9320970a 100644 --- a/packages/clients/diplan/example/setup.js +++ b/packages/clients/diplan/example/setup.js @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function */ -import { validateForm } from './authentication/validateForm' +import { validateForm } from './authentication/validateForm.js' const geoJSON = { type: 'FeatureCollection', From a0af7c093295fd6440f1eaa673b637b554630a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20R=C3=B6hling?= <73653210+dopenguin@users.noreply.github.com> Date: Wed, 26 Feb 2025 17:32:38 +0100 Subject: [PATCH 5/7] Remove redundant optional chaining Co-authored-by: Dennis Sen <108349707+warm-coolguy@users.noreply.github.com> --- packages/core/src/utils/createMap/addInterceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/createMap/addInterceptor.ts b/packages/core/src/utils/createMap/addInterceptor.ts index 83b6cf4e4..4d55ca4b0 100644 --- a/packages/core/src/utils/createMap/addInterceptor.ts +++ b/packages/core/src/utils/createMap/addInterceptor.ts @@ -18,7 +18,7 @@ export function addInterceptor(interceptorUrlRegex: string) { if ( interceptorUrlRegex && typeof resource === 'string' && - resource?.match(interceptorUrlRegex) + resource.match(interceptorUrlRegex) ) { config = { ...originalConfig, From d94a7f8d5908c302baa73f2f17d7f0881f42e3bb Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Wed, 26 Feb 2025 17:51:00 +0100 Subject: [PATCH 6/7] Change configuration to also include name of the token Also, the Authorization header is only overwritten if it is not present yet. --- .../diplan/example/authentication/validateForm.js | 4 ++-- packages/core/CHANGELOG.md | 2 +- packages/core/README.md | 12 +++++++++++- .../core/src/utils/createMap/addInterceptor.ts | 15 +++++++++++---- packages/core/src/utils/createMap/index.ts | 4 ++-- packages/types/custom/CHANGELOG.md | 3 ++- packages/types/custom/core.ts | 7 ++++++- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/clients/diplan/example/authentication/validateForm.js b/packages/clients/diplan/example/authentication/validateForm.js index 8455fa741..65cca49f3 100644 --- a/packages/clients/diplan/example/authentication/validateForm.js +++ b/packages/clients/diplan/example/authentication/validateForm.js @@ -1,6 +1,6 @@ import { authenticate } from './index.js' -export function validateForm(interceptorUrlRegex) { +export function validateForm() { const username = document.getElementById('username').value const password = document.getElementById('password').value const errorMessage = document.getElementById('error-message') @@ -10,6 +10,6 @@ export function validateForm(interceptorUrlRegex) { 'Bitte geben Sie sowohl einen Nutzernamen als auch ein Passwort ein.' } else { errorMessage.textContent = '' - authenticate(username, password, interceptorUrlRegex) + authenticate(username, password) } } diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index b48648e78..e2ad27cb1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -2,7 +2,7 @@ ## unpublished -- Feature: Add possibility of using OIDC secured services by using the configuration parameter `interceptorUrlRegex`. +- Feature: Add possibility of using OIDC secured services by using the configuration parameter `authentication`. - Fix: If a flag `_isPolarDragLikeInteraction` is present on any interaction, the page will stop scrolling in mobile mode, and the interaction takes precendence. Especially, this is done to prevent the tooltip on how to pan the map on mobile devices to appear. This flag is documented at the end of the README.md. ## 3.0.0 diff --git a/packages/core/README.md b/packages/core/README.md index 6f13241e6..22436d63b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -88,10 +88,10 @@ The mapConfiguration allows controlling many client instance details. | fieldName | type | description | | - | - | - | | <...masterportalapi.fields> | various | Multiple different parameters are required by the masterportalapi to be able to create the map. Also, some fields are optional but relevant and thus described here as well. For all additional options, refer to the documentation of the masterportalapi itself. | +| authentication | authentication? | Optional. If set, the client will use the given authentication configuration. | | checkServiceAvailability | boolean? | If set to `true`, all services' availability will be checked with head requests. | | extendedMasterportalapiMarkers | extendedMasterportalapiMarkers? | Optional. If set, all configured visible vector layers' features can be hovered and selected by mouseover and click respectively. They are available as features in the store. Layers with `clusterDistance` will be clustered to a multi-marker that supports the same features. Please mind that this only works properly if you configure nothing but point marker vector layers styled by the masterportalapi. | | featureStyles | string? | Optional path to define styles for vector features. See `mapConfiguration.featureStyles` for more information. May be a url or a path on the local file system. | -| interceptorUrlRegex | string? | Optional regular expression defining URLs that belong to secured services. All requests sent to URLs that fit the regular expression will send the JSON Web Token (JWT) found as a cookie named `'token'` as a Bearer token in the Authorization header of the request. | | language | enum["de", "en"]? | Initial language. | | locales | Locale[]? | All locales in POLAR's plugins can be overridden to fit your needs.| | | various? | Fields for configuring plugins added with `addPlugins`. Refer to each plugin's documentation for specific fields and options. Global plugin parameters are described [below](#global-plugin-parameters). | @@ -145,6 +145,16 @@ const mapConfiguration = { +##### mapConfiguration.authentication + +All requests sent to URLs that fit the regular expression will send the JSON Web Token (JWT) found as a cookie with the configured name as a Bearer token in the Authorization header of the request. +Requests already including a Authorization header will keep the already present one. + +| fieldName | type | description | +| - | - | - | +| interceptorUrlRegex | string | Regular expression defining URLs that belong to secured services. | +| tokenName | string | Name of the cookie that holds the JWT. | + ##### mapConfiguration.Locale A language option is an object consisting of a type (its language key) and the i18next resource definition. You may e.g. decide that the texts offered in the LayerChooser do not fit the style of your client, or that they could be more precise in your situation since you're only using *very specific* overlays. diff --git a/packages/core/src/utils/createMap/addInterceptor.ts b/packages/core/src/utils/createMap/addInterceptor.ts index 4d55ca4b0..b0703bd19 100644 --- a/packages/core/src/utils/createMap/addInterceptor.ts +++ b/packages/core/src/utils/createMap/addInterceptor.ts @@ -1,3 +1,4 @@ +import { AuthenticationConfig } from '@polar/lib-custom-types' import { getCookie } from '../getCookie' /** @@ -7,9 +8,12 @@ import { getCookie } from '../getCookie' * Function is based on functionality from * https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsAxios.js * - * @param interceptorUrlRegex - URLs fitting this regular expression have the token added. + * @param authConfig - Configuration object of the authentication. */ -export function addInterceptor(interceptorUrlRegex: string) { +export function addInterceptor({ + interceptorUrlRegex, + tokenName, +}: AuthenticationConfig) { const { fetch: originalFetch } = window window.fetch = (resource, originalConfig) => { @@ -22,8 +26,11 @@ export function addInterceptor(interceptorUrlRegex: string) { ) { config = { ...originalConfig, - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: { Authorization: `Bearer ${getCookie('token')}` }, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Authorization: `Bearer ${getCookie(tokenName)}` || '', + ...originalConfig?.headers, + }, } } diff --git a/packages/core/src/utils/createMap/index.ts b/packages/core/src/utils/createMap/index.ts index 6b649fa28..1c51f91b6 100644 --- a/packages/core/src/utils/createMap/index.ts +++ b/packages/core/src/utils/createMap/index.ts @@ -52,8 +52,8 @@ export default async function createMap({ setupFontawesome(shadowRoot, mapConfiguration.renderFaToLightDom) updateSizeOnReady(instance) - if (mapConfiguration.interceptorUrlRegex) { - addInterceptor(mapConfiguration.interceptorUrlRegex) + if (mapConfiguration.authentication) { + addInterceptor(mapConfiguration.authentication) } // Restore theme ID such that external Vuetify app can find it again if (externalStylesheet) { diff --git a/packages/types/custom/CHANGELOG.md b/packages/types/custom/CHANGELOG.md index fa319a7f8..12f9fea3e 100644 --- a/packages/types/custom/CHANGELOG.md +++ b/packages/types/custom/CHANGELOG.md @@ -4,7 +4,8 @@ - Feature: Add `snapTo` to `DrawConfiguration` for specification of layers to snap to. - Feature: Add `lassos` to `DrawConfiguration`. With this, `addLoading`, `removeLoading`, and `toastAction` have also been introduced to allow the feature to use other plugins via API calls, and `Lasso` itself has been introduced. -- Feature: Add new configuration parameter `interceptorUrlRegex` to `MapConfig`. +- Feature: Add new optional configuration parameter `authentication` to `MapConfig`. +- Feature: Add new type `AuthenticationConfiguration`. ## 2.0.0 diff --git a/packages/types/custom/core.ts b/packages/types/custom/core.ts index d7fefb9b2..51b91abba 100644 --- a/packages/types/custom/core.ts +++ b/packages/types/custom/core.ts @@ -599,6 +599,11 @@ export interface PolarMapOptions { zoomLevel: number } +export interface AuthenticationConfig { + interceptorUrlRegex: string + tokenName: string +} + /** The initial language the client should be using; defaults to 'de' if not given */ export type InitialLanguage = 'de' | 'en' @@ -658,11 +663,11 @@ export interface MasterportalApiConfig { export interface MapConfig extends MasterportalApiConfig { /** Configured layers */ layers: LayerConfiguration[] + authentication?: AuthenticationConfig /** if true, all services' availability will be checked with head requests */ checkServiceAvailability?: boolean extendedMasterportalapiMarkers?: ExtendedMasterportalapiMarkers featureStyles?: string - interceptorUrlRegex?: string language?: InitialLanguage locales?: Locale[] renderFaToLightDom?: boolean From f2b199f7d3bfec08558fd78960e3f0ec3212fc75 Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Wed, 26 Feb 2025 17:59:19 +0100 Subject: [PATCH 7/7] Reduce saved cookies and minimize example --- .../diplan/example/authentication/cookie.js | 31 ++-------------- .../diplan/example/authentication/index.js | 21 ++++------- .../diplan/example/authentication/parseJWT.js | 35 ------------------- 3 files changed, 9 insertions(+), 78 deletions(-) delete mode 100644 packages/clients/diplan/example/authentication/parseJWT.js diff --git a/packages/clients/diplan/example/authentication/cookie.js b/packages/clients/diplan/example/authentication/cookie.js index 01f742ea1..dfece64f4 100644 --- a/packages/clients/diplan/example/authentication/cookie.js +++ b/packages/clients/diplan/example/authentication/cookie.js @@ -1,13 +1,9 @@ -import { parseJWT } from './parseJWT.js' - /* * Functions are based on https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsCookies.js * and https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsOIDC.js. */ /** - * Set a cookie value. - * * @param {string} name of cookie to set. * @param {string} value to set into a cookie. */ @@ -21,8 +17,6 @@ export function setCookie(name, value) { } /** - * Gets value of a cookie. - * * @param {string} name of cookie to retrieve. * @returns {string} cookie value. */ @@ -43,28 +37,9 @@ export function getCookie(name) { return undefined } -export function setCookies(token, expiresIn, refreshToken) { - setCookie('token', token) - setCookie('expires_in', expiresIn) - setCookie('refresh_token', refreshToken) - - const account = parseJWT(token) - - setCookie('name', account?.name) - setCookie('email', account?.email) - setCookie('username', account?.preferred_username) - - setCookie('expiry', account?.exp) -} - /** - * Delete all cookies with names given in list - * - * @param {String[]} names of cookies to delete - * @return {void} + * @param {string} name of the cookie to delete. */ -export function eraseCookies(names) { - names.forEach((name) => { - document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;` - }) +export function deleteCookie(name) { + document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;` } diff --git a/packages/clients/diplan/example/authentication/index.js b/packages/clients/diplan/example/authentication/index.js index daf1e79b5..5c3599612 100644 --- a/packages/clients/diplan/example/authentication/index.js +++ b/packages/clients/diplan/example/authentication/index.js @@ -1,13 +1,13 @@ -import { eraseCookies, getCookie, setCookies } from './cookie.js' +import { deleteCookie, getCookie, setCookie } from './cookie.js' const clientId = 'polar' const scope = 'openid' let loggedIn = false -export async function authenticate(username, password) { +export async function authenticate(username, password, { tokenName }) { if (loggedIn) { - await reset() + await reset(tokenName) return } @@ -26,7 +26,7 @@ export async function authenticate(username, password) { }) if (response.ok) { const data = await response.json() - setCookies(data.access_token, data.expires_in, data.refresh_token) + setCookie(tokenName, data.access_token) document.getElementById('login-button').textContent = 'Logout' document.getElementById('username').disabled = true document.getElementById('password').disabled = true @@ -38,18 +38,9 @@ export async function authenticate(username, password) { // TODO: Add UI element to a layer if it can only be used via authentication (lock / unlock) } -async function reset() { +async function reset(tokenName) { await revokeToken(getCookie('token')) - await revokeToken(getCookie('refresh_token')) - eraseCookies([ - 'token', - 'expires_in', - 'refresh_token', - 'name', - 'username', - 'email', - 'expiry', - ]) + deleteCookie(tokenName) window.location.reload() } diff --git a/packages/clients/diplan/example/authentication/parseJWT.js b/packages/clients/diplan/example/authentication/parseJWT.js deleted file mode 100644 index 470921d4d..000000000 --- a/packages/clients/diplan/example/authentication/parseJWT.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Parses jwt token. This function does *not* validate the token. - * Function is based on https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/src/modules/login/js/utilsOIDC.js. - * - * @param {string} token jwt token to be parsed. - * @returns {object} parsed jwt token as object - */ -export function parseJWT(token) { - try { - if (!token) { - return {} - } - - const base64Url = token.split('.')[1] - if (!base64Url) { - return {} - } - - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') - return JSON.parse( - decodeURIComponent( - window - .atob(base64) - .split('') - .map(function (c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) - }) - .join('') - ) - ) - } catch (e) { - // If token is not valid or another error occurs, return an empty object - return {} - } -}