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

Feature/secure services #249

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
70 changes: 70 additions & 0 deletions packages/clients/diplan/example/authentication/cookie.js
Original file line number Diff line number Diff line change
@@ -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.
*/
Comment on lines +1 to +4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using cookies at all? There seems to be no technical reason to do it, but now we have to worry about cookie laws. Has there been any communication with the customer regarding this? The last state I remember is that we get the token by configuration and thus, it should be fetchable from the store.configuration.token or something like that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configuration option was something I had in mind initially but scrapped quickly.

There are two options that can solve using the token:

  • Using the token when calling e.g. createMap
  • Requesting the token from the Cookie

If we'd get the token from the call to createMap, we'd have to handle the situation when a token expires and request a new one as the function call is already done. If we'd be doing that with the configuration, the situation would be similar.

By having the token in a cookie, which is usually done by applications that use OIDC tokens for authentication anyway, we can simply request it on the go and not worry about refreshing it on our own.

Do you think an adjustment to the documentation is necessary so that it is clear that interceptorUrlRegex should only be used by integrated clients as we currently do not offer a login module?

Copy link
Member

@warm-coolguy warm-coolguy Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createMap parameter combined with an action provided to update the token for our application when needed should be the way to go here. This way we initially have a token to start working with and delegate its updates to the leading application that may now refresh and keep it however it desires to – forwarding it to the map is a follow-up step.

This also spares the leading application to be forced to produce a cookie token, which it may not have intended to, or which may also already be in use for another token since that's a very generic word – since the client is supposed to be used in multiple spots eventually, even both may hold true in time.

This way we're also keeping data flow to the etablished channels and don't open up a new one.

Do you think an adjustment to the documentation is necessary so that it is clear that interceptorUrlRegex should only be used by integrated clients as we currently do not offer a login module?

I'd like it to be used in combination with a config parameter and an obligation to refresh it via action, which is a mechanism that should be documented, but is debated atm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the token can now be configured, as mentioned in #249 (comment)

Whether the authentication shall be achieved by retrieving a cookie or giving the JWT value via createMap and handling the refresh in POLAR (as well) is now something I am discussing with the relevant person that will integrate POLAR later on.


/**
* 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)
Copy link
Member

@warm-coolguy warm-coolguy Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why save this? We seem to just require the token. Probably illegal, too, even though we don't use it anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applications typically save these values, similarly to #249 (comment). We can keep the example simple you like

Copy link
Member

@warm-coolguy warm-coolguy Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please keep the example minimal. It will be a guideline for implementers on how to achieve things, and they may mimic this and later wonder what to do with the variables.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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;`
})
}
73 changes: 73 additions & 0 deletions packages/clients/diplan/example/authentication/index.js
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this an F5? Doesn't it completely eradicate all of the user's progress?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. But if a user would log out in an application that includes POLAR, this would be the case in most situation. Or am I on the wrong track?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the method authenticate is also supposed to do the logout. I expected this to be a token refresh since it calls revokeToken that has grant_type=refresh_token hard-wired. Now that I look at it, the fetch also always uses the URL ''? Is this just unfinished? I guess we come back around to this when the other issues are resolved, the code may have changed until then anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can take another look regarding the grant_type (as in: is it the correct one).
The URL is the same thing as with the rest: I'll add this to the repository once we have one that can be put into OSS. Until then, use the URL I provided you through a save channel.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, I forgot that it's in the git patch.

Copy link
Member Author

@dopenguin dopenguin Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The url used ends with /revoke and the grant_type is also correct for this case.

}

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.'
}
35 changes: 35 additions & 0 deletions packages/clients/diplan/example/authentication/parseJWT.js
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
15 changes: 15 additions & 0 deletions packages/clients/diplan/example/authentication/validateForm.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions packages/clients/diplan/example/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default {
minLength: 3,
waitMs: 300,
},
interceptorUrlRegex: '',
layers: [
{
id: basemap,
Expand Down Expand Up @@ -84,6 +85,12 @@ export default {
type: 'mask',
name: `diplan.layers.${bstgasleitung}`,
},
{
id: 'secureServiceTest',
visibility: false,
type: 'mask',
name: 'Secure Service Test',
},
],
attributions: {
layerAttributions: [
Expand Down
16 changes: 16 additions & 0 deletions packages/clients/diplan/example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ <h1>Testseite <code>@polar/client-diplan</code> (dev)</h1>
<main>
<section>
<h2 class="padded-x">Beispielinstanz</h2>
<div class="padded-x">
<h3>Loginbeispiel</h3>
<form id="login-form" style="display: flex; gap: 10px;">
<label>
Nutzername:
<input type="text" id="username" name="username" required>
</label>
<label>
Passwort:
<input type="password" id="password" name="password" required>
</label>
<br>
<button id="login-button" type="button">Login</button>
</form>
<p id="error-message" style="color: red;"></p>
</div>
<div class="padded-x">
<h3>Steuerung von außen</h3>
<button id="action-plus">+</button>
Expand Down
8 changes: 8 additions & 0 deletions packages/clients/diplan/example/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
]
5 changes: 5 additions & 0 deletions packages/clients/diplan/example/setup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable max-lines-per-function */

import { validateForm } from './authentication/validateForm'

const geoJSON = {
type: 'FeatureCollection',
features: [
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## unpublished

- Feature: Add possibility of using OIDC secured services by using the configuration parameter `interceptorUrlRegex`.
- 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
Expand Down
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.|
| <plugin.fields> | 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). |
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/utils/createMap/addInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we share the window element with the outlying application, we're now potentially changing its behaviour. This is quite dangerous and we may break stuff, so I don't deem this mergable, especially since we're plain overriding any headers sent to the target.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a suggestion on how to add an interceptor here for only polar instead of the whole application? I don't seem to have come across a reasonable solution so far.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first idea was to identify the call source, and only act if it was from within POLAR. Since .caller is deprecated, checking the call stack seems to be a non-solution, and we can't just modify all local requests since OL and the masterportalAPI may start them, too.

The only thing left seems to be the URL itself. We may modify all URLs intended for interception on arrival. Since everything flows through the client creation (either by config or from the services.json), all URLs may run through a check on whether they fit the interceptorUrlRegex. If so, we modify them to hold an appropriate flag.

Ideas on how to mark URLs:

  • ${url}+POLAR_INTERCEPTOR+ or another string that will absolutely not randomly appear in the finite time that remains to the universe – however, if there's any operations on the end of the URL, this may or may not break anything?
  • I wonder whether ?, &, or # may also be made use of, like e.g. in ${url}?polar_interceptor_was_here=true – this may survive further URL modifications since it's technically a correct URL, but I wonder if some URLs are required to not have any appendices like that by e.g. the masterportalAPI?
  • ${scheme}${subdomain}POLAR_INTERCEPTOR.${domain} would hide that as an additional subdomain, and I'm pretty sure it will survive all underlying requests there until fetch is finally used.

Then, in the interceptor, the flag is removed before the real URL is used with the Auth header. This way all fetch calls of the outlying application to the URL are unmodified, and all of our own calls are modified.

That or you don't override all original headers and only add Authorization when it's not there already.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've adjusted the configuration so that the name of the token is also configurable.
The changes to the headers is also implemented. d94a7f8

Is it fine like this now or do you deem the marking of the URLs necessary as well?

4 changes: 4 additions & 0 deletions packages/core/src/utils/createMap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/utils/getCookie.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions packages/types/custom/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/types/custom/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ export interface MapConfig extends MasterportalApiConfig {
checkServiceAvailability?: boolean
extendedMasterportalapiMarkers?: ExtendedMasterportalapiMarkers
featureStyles?: string
interceptorUrlRegex?: string
language?: InitialLanguage
locales?: Locale[]
renderFaToLightDom?: boolean
Expand Down