diff --git a/source/core/oauth/OAuthClient.js b/source/core/oauth/OAuthClient.js new file mode 100644 index 000000000..9c219e71d --- /dev/null +++ b/source/core/oauth/OAuthClient.js @@ -0,0 +1,94 @@ +class OAuthClient { + constructor(config) { + if (!config) { + 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, + }) + + 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 + } + + 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 + } + })() + + return this._refreshPromise + } + + async getValidToken() { + if (this.isExpired()) { + await this.refreshAccessToken() + } + return this.token + } +} + +/* + * 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. +*/ + +let oAuthClientInstance = null + +export function configureOAuthClient(config) { + if (!config || !config.clientId || !config.clientSecret || !config.tokenEndpoint) { + console.warn('configureOAuthClient called without complete config; OAuth disabled.') + return null + } + oAuthClientInstance = new OAuthClient(config) + return oAuthClientInstance +} + +export function getOAuthClient() { + return oAuthClientInstance +} + +export function resetOAuthClient() { + oAuthClientInstance = null +} diff --git a/source/core/sweapi/Collection.js b/source/core/sweapi/Collection.js index 24e157624..f7885e93f 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,19 @@ class Collection { const queryString = `${this.filter.toQueryString()}&offset=${offset}&limit=${this.pageSize}`; const fullUrl = this.url + '?' + queryString; + const headers = {}; + const oAuthClient = getOAuthClient(); + if (oAuthClient !== null) { + const token = await oAuthClient.getValidToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + } + 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 4efe0894f..bbe1288bb 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 { @@ -102,63 +103,66 @@ class SensorWebApi { this._network.stream.connector.connect(); } - getHeaders() { - const headers = { - }; + async getHeaders() { + 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); + const oAuthClient = getOAuthClient(); + if (oAuthClient !== null) { + const token = await oAuthClient.getValidToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + } 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(let key in this.networkProperties.connectorOpts) { - headers[key] = this.networkProperties.connectorOpts[key]; + 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;