From 6e25c58ff1afb6251cbb026228acf3c725ff9fc1 Mon Sep 17 00:00:00 2001 From: Cardy2 Date: Mon, 11 May 2026 17:37:53 -0400 Subject: [PATCH 1/4] Add OAuthClient and update sweapi/Collection.js & SensorWebApi to include OAuthClient implementations --- source/core/oauth/OAuthClient.js | 89 ++++++++++++++++++++++++++++++ source/core/sweapi/Collection.js | 11 +++- source/core/sweapi/SensorWebApi.js | 24 +++++--- 3 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 source/core/oauth/OAuthClient.js diff --git a/source/core/oauth/OAuthClient.js b/source/core/oauth/OAuthClient.js new file mode 100644 index 0000000000..6dfbc92809 --- /dev/null +++ b/source/core/oauth/OAuthClient.js @@ -0,0 +1,89 @@ +class OAuthClient { + constructor(config) { + if (!config) { + throw new Error('BearerToken requires a config object') + } + + this.config = config + this.token = null + this.expirationTime = 0 + } + + getToken() { + return this.token + } + + async refreshAccessToken() { + const data = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }) + + try { + const response = await fetch(this.config.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data.toString(), + }) + + if (!response.ok) { + console.error(`Failed to retrieve access token: ${response.status}`) + return + } + + const json = await response.json() + this.token = json.access_token + this.expirationTime = Date.now() + json.expires_in * 1000 + } catch (error) { + console.error(`Failed to retrieve access token due to exception: ${error.message}`) + } + } + + isExpired() { + return Date.now() > this.expirationTime + } +} + +// Singleton instance holder +let bearerTokenInstance = null +let oAuthConfigured = false; +let doOnce = true; + +function checkOAuthConfigured() { + + if (!import.meta.env.VITE_CLIENT_ID || !import.meta.env.VITE_CLIENT_SECRET || !import.meta.env.VITE_TOKEN_ENDPOINT) { + console.warn('OAuth not configured!') + } else { + oAuthConfigured = true; + } + doOnce = false; +} + +function createOAuthClient(config) { + + if (!bearerTokenInstance) { + bearerTokenInstance = new OAuthClient(config) + oAuthConfigured = true; + } + return bearerTokenInstance +} + +export function getOAuthClient() { + + if (doOnce) { + checkOAuthConfigured(); + } + // Send access grant information if it is defined + if (oAuthConfigured && bearerTokenInstance === null) { + createOAuthClient({ + clientId: import.meta.env.VITE_CLIENT_ID, + clientSecret: import.meta.env.VITE_CLIENT_SECRET, + tokenEndpoint: import.meta.env.VITE_TOKEN_ENDPOINT, + }) + } + + return bearerTokenInstance +} diff --git a/source/core/sweapi/Collection.js b/source/core/sweapi/Collection.js index 24e1576247..593f377bb2 100644 --- a/source/core/sweapi/Collection.js +++ b/source/core/sweapi/Collection.js @@ -15,6 +15,7 @@ ******************************* END LICENSE BLOCK ***************************/ import SweCollectionDataParser from "../parsers/sweapi/collection/SweCollectionDataParser"; +import {getOAuthClient} from "osh-js/source/core/oauth/OAuthClient"; class Collection { /** @@ -45,10 +46,18 @@ class Collection { const queryString = `${this.filter.toQueryString()}&offset=${offset}&limit=${this.pageSize}`; const fullUrl = this.url + '?' + queryString; + let headers = {}; + if (getOAuthClient() !== null) { + if (getOAuthClient().isExpired()) { + await getOAuthClient().refreshAccessToken(); + headers['Authorization'] = 'Bearer ' + getOAuthClient().getToken(); + } + } + const jsonResponse = await fetch(fullUrl, { method: 'GET', credentials: 'include', - headers: {} + headers: headers }).then((response) => { if (!response.ok) { const err = new Error(`Got ${response.status} response from ${fullUrl}`); diff --git a/source/core/sweapi/SensorWebApi.js b/source/core/sweapi/SensorWebApi.js index 4efe0894ff..d4be36b1ec 100644 --- a/source/core/sweapi/SensorWebApi.js +++ b/source/core/sweapi/SensorWebApi.js @@ -18,6 +18,7 @@ import WebSocketConnector from "../connector/WebSocketConnector"; import {assertDefined, isDefined} from "../utils/Utils"; import MqttTopicConnector from "../connector/MqttTopicConnector"; import MqttConnector from "../connector/MqttConnector"; +import {getOAuthClient} from "osh-js/source/core/oauth/OAuthClient"; class SensorWebApi { @@ -106,13 +107,22 @@ class SensorWebApi { const headers = { }; - if('connectorOpts' in this.networkProperties){ - if('username' in this.networkProperties.connectorOpts && 'password' in this.networkProperties.connectorOpts) { - headers['Authorization'] = 'Basic ' + - btoa(this.networkProperties.connectorOpts.username + ":" + this.networkProperties.connectorOpts.password); - } else { - for(let key in this.networkProperties.connectorOpts) { - headers[key] = this.networkProperties.connectorOpts[key]; + if (getOAuthClient() !== null) { + if (getOAuthClient().isExpired()) { + (async () => { + await getOAuthClient().refreshAccessToken(); + headers['Authorization'] = 'Bearer ' + getOAuthClient().getToken(); + })(); + } + } else { + if('connectorOpts' in this.networkProperties){ + if('username' in this.networkProperties.connectorOpts && 'password' in this.networkProperties.connectorOpts) { + headers['Authorization'] = 'Basic ' + + btoa(this.networkProperties.connectorOpts.username + ":" + this.networkProperties.connectorOpts.password); + } else { + for(let key in this.networkProperties.connectorOpts) { + headers[key] = this.networkProperties.connectorOpts[key]; + } } } } From cd22078bd94f9dd4a4de0ed317ce69a04f419999 Mon Sep 17 00:00:00 2001 From: Cardy2 Date: Wed, 13 May 2026 13:32:35 -0400 Subject: [PATCH 2/4] Make OAuthClient.js bundler agnostic by removing import.meta.env; add refresh as needed to getValidToken --- source/core/oauth/OAuthClient.js | 109 ++++++++++++++++--------------- 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/source/core/oauth/OAuthClient.js b/source/core/oauth/OAuthClient.js index 6dfbc92809..9c219e71d9 100644 --- a/source/core/oauth/OAuthClient.js +++ b/source/core/oauth/OAuthClient.js @@ -1,89 +1,94 @@ class OAuthClient { constructor(config) { if (!config) { - throw new Error('BearerToken requires a config object') + throw new Error('OAuthClient requires a config object') } this.config = config this.token = null this.expirationTime = 0 + this._refreshPromise = null } getToken() { return this.token } + isExpired() { + return Date.now() > this.expirationTime + } + async refreshAccessToken() { + // Dedupe concurrent refreshes so parallel requests share one token call. + if (this._refreshPromise) { + return this._refreshPromise + } + const data = new URLSearchParams({ grant_type: 'client_credentials', client_id: this.config.clientId, client_secret: this.config.clientSecret, }) - try { - const response = await fetch(this.config.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: data.toString(), - }) + this._refreshPromise = (async () => { + try { + const response = await fetch(this.config.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data.toString(), + }) - if (!response.ok) { - console.error(`Failed to retrieve access token: ${response.status}`) - return + if (!response.ok) { + console.error(`Failed to retrieve access token: ${response.status}`) + return + } + + const json = await response.json() + this.token = json.access_token + this.expirationTime = Date.now() + json.expires_in * 1000 + } catch (error) { + console.error(`Failed to retrieve access token due to exception: ${error.message}`) + } finally { + this._refreshPromise = null } + })() - const json = await response.json() - this.token = json.access_token - this.expirationTime = Date.now() + json.expires_in * 1000 - } catch (error) { - console.error(`Failed to retrieve access token due to exception: ${error.message}`) - } + return this._refreshPromise } - isExpired() { - return Date.now() > this.expirationTime + async getValidToken() { + if (this.isExpired()) { + await this.refreshAccessToken() + } + return this.token } } -// Singleton instance holder -let bearerTokenInstance = null -let oAuthConfigured = false; -let doOnce = true; - -function checkOAuthConfigured() { - - if (!import.meta.env.VITE_CLIENT_ID || !import.meta.env.VITE_CLIENT_SECRET || !import.meta.env.VITE_TOKEN_ENDPOINT) { - console.warn('OAuth not configured!') - } else { - oAuthConfigured = true; - } - doOnce = false; -} +/* + * Singleton instance holder. + * The host application is responsible for calling configureOAuthClient() at + * startup with credentials sourced from wherever it sees fit (env vars, + * runtime config, user input, etc.). The library itself does not read any + * build-time env vars, so it stays bundler-agnostic. +*/ -function createOAuthClient(config) { +let oAuthClientInstance = null - if (!bearerTokenInstance) { - bearerTokenInstance = new OAuthClient(config) - oAuthConfigured = true; +export function configureOAuthClient(config) { + if (!config || !config.clientId || !config.clientSecret || !config.tokenEndpoint) { + console.warn('configureOAuthClient called without complete config; OAuth disabled.') + return null } - return bearerTokenInstance + oAuthClientInstance = new OAuthClient(config) + return oAuthClientInstance } export function getOAuthClient() { + return oAuthClientInstance +} - if (doOnce) { - checkOAuthConfigured(); - } - // Send access grant information if it is defined - if (oAuthConfigured && bearerTokenInstance === null) { - createOAuthClient({ - clientId: import.meta.env.VITE_CLIENT_ID, - clientSecret: import.meta.env.VITE_CLIENT_SECRET, - tokenEndpoint: import.meta.env.VITE_TOKEN_ENDPOINT, - }) - } - - return bearerTokenInstance +export function resetOAuthClient() { + oAuthClientInstance = null } From 531eb28ed7aca4fcba173d4b8cf99601a931abaf Mon Sep 17 00:00:00 2001 From: Cardy2 Date: Wed, 13 May 2026 13:34:04 -0400 Subject: [PATCH 3/4] Collection.js fetchData() always attach token if exists --- source/core/sweapi/Collection.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/source/core/sweapi/Collection.js b/source/core/sweapi/Collection.js index 593f377bb2..f7885e93fe 100644 --- a/source/core/sweapi/Collection.js +++ b/source/core/sweapi/Collection.js @@ -46,11 +46,12 @@ class Collection { const queryString = `${this.filter.toQueryString()}&offset=${offset}&limit=${this.pageSize}`; const fullUrl = this.url + '?' + queryString; - let headers = {}; - if (getOAuthClient() !== null) { - if (getOAuthClient().isExpired()) { - await getOAuthClient().refreshAccessToken(); - headers['Authorization'] = 'Bearer ' + getOAuthClient().getToken(); + const headers = {}; + const oAuthClient = getOAuthClient(); + if (oAuthClient !== null) { + const token = await oAuthClient.getValidToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; } } From 21b87220326080a1bcffc6c724dcecb9e045903f Mon Sep 17 00:00:00 2001 From: Cardy2 Date: Wed, 13 May 2026 13:35:03 -0400 Subject: [PATCH 4/4] make SensorWebApi.js getHeader() async & 2 HTTP calling methods await it - race fix, always attach --- source/core/sweapi/SensorWebApi.js | 84 ++++++++++++++---------------- 1 file changed, 39 insertions(+), 45 deletions(-) diff --git a/source/core/sweapi/SensorWebApi.js b/source/core/sweapi/SensorWebApi.js index d4be36b1ec..bbe1288bb9 100644 --- a/source/core/sweapi/SensorWebApi.js +++ b/source/core/sweapi/SensorWebApi.js @@ -103,72 +103,66 @@ class SensorWebApi { this._network.stream.connector.connect(); } - getHeaders() { - const headers = { - }; - - if (getOAuthClient() !== null) { - if (getOAuthClient().isExpired()) { - (async () => { - await getOAuthClient().refreshAccessToken(); - headers['Authorization'] = 'Bearer ' + getOAuthClient().getToken(); - })(); + async getHeaders() { + const headers = {}; + + const oAuthClient = getOAuthClient(); + if (oAuthClient !== null) { + const token = await oAuthClient.getValidToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; } - } else { - if('connectorOpts' in this.networkProperties){ - if('username' in this.networkProperties.connectorOpts && 'password' in this.networkProperties.connectorOpts) { - headers['Authorization'] = 'Basic ' + - btoa(this.networkProperties.connectorOpts.username + ":" + this.networkProperties.connectorOpts.password); - } else { - for(let key in this.networkProperties.connectorOpts) { - headers[key] = this.networkProperties.connectorOpts[key]; - } + } else if ('connectorOpts' in this.networkProperties) { + const opts = this.networkProperties.connectorOpts; + if ('username' in opts && 'password' in opts) { + headers['Authorization'] = 'Basic ' + btoa(opts.username + ':' + opts.password); + } else { + for (const key in opts) { + headers[key] = opts[key]; } } } return headers; } - fetchAsJson(apiUrl, queryString) { - const fullUrl = this.baseUrl() + apiUrl + '?' +queryString; + async fetchAsJson(apiUrl, queryString) { + const fullUrl = this.baseUrl() + apiUrl + '?' + queryString; - const headers = this.getHeaders(); + const headers = await this.getHeaders(); - return fetch(fullUrl, { - method: 'GET', - credentials: 'include', - headers: headers - } - ).then(function (response) { - if (!response.ok) { - const err = new Error(`Got ${response.status} response from ${this.baseUrl()}`); - err.response = response; - throw err; - } - return response.json(); + const response = await fetch(fullUrl, { + method: 'GET', + credentials: 'include', + headers: headers }); - } - postAsJson(apiUrl, jsonPayload) { - const fullUrl = this.baseUrl() + apiUrl; + if (!response.ok) { + const err = new Error(`Got ${response.status} response from ${this.baseUrl()}`); + err.response = response; + throw err; + } + return response.json(); + } - const headers = this.getHeaders(); + async postAsJson(apiUrl, jsonPayload) { + const fullUrl = this.baseUrl() + apiUrl; + const headers = await this.getHeaders(); headers['Accept'] = 'application/json'; headers['Content-Type'] = 'application/json'; - fetch(fullUrl, { + const response = await fetch(fullUrl, { method: 'POST', headers: headers, credentials: 'include', body: jsonPayload - }).then(function (response) { - if (!response.ok) { - const err = new Error(`Got ${response.status} response from ${fullUrl}`); - err.response = response; - throw err; - } }); + + if (!response.ok) { + const err = new Error(`Got ${response.status} response from ${fullUrl}`); + err.response = response; + throw err; + } } } export default SensorWebApi;