diff --git a/auth/token-manager.js b/auth/token-manager.js new file mode 100644 index 0000000..c48786f --- /dev/null +++ b/auth/token-manager.js @@ -0,0 +1,135 @@ +/** + * Token Manager for RERUM Auth0 integration. + * + * This module handles automatic access-token refresh using the existing + * RERUM/Auth0 refresh-token flow. It does NOT create or manage tokens + * independently; instead it proxies token refresh requests through the + * configured Auth0/RERUM token endpoint + */ + +import config from '../config/index.js' +import fs from 'node:fs/promises' + +const sourcePath = '.env' + +// Checks if a JWT token is expired based on its 'exp' claim. +const isTokenExpired = (token) => { + if (!token) return true + + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64').toString() + ) + + return !payload.exp || Date.now() >= payload.exp * 1000 + } catch (err) { + console.error('Failed to parse token:', err) + return true + } +} + +/** Generates a new access token using the stored refresh token. + * The refresh token must come from the Auth0 UX registration/login flow. + * If no refresh token is available, the server cannot request a new + * access token automatically. + +*/ +async function generateNewAccessToken() { + const refreshToken = config.REFRESH_TOKEN || process.env.REFRESH_TOKEN + const tokenUrl = config.RERUM_ACCESS_TOKEN_URL || process.env.RERUM_ACCESS_TOKEN_URL + + if (!refreshToken) { + throw new Error( + 'No refresh token available. Please register through the Auth0 UX flow first.' + ) + } + + if (!tokenUrl) { + throw new Error('No token refresh URL configured.') + } + + // Request a new access token from the Auth0/RERUM token endpoint + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refreshToken }) + }) + + const tokenObject = await response.json() + + // Handle HTTP or API errors + if (!response.ok) { + throw new Error( + tokenObject.error_description || + tokenObject.error || + 'Token refresh failed' + ) + } + + process.env.ACCESS_TOKEN = tokenObject.access_token + + // Auth0 may return a new refresh token depending on configuration + if (tokenObject.refresh_token) { + process.env.REFRESH_TOKEN = tokenObject.refresh_token + } + + try { + const data = await fs.readFile(sourcePath, { encoding: 'utf8' }) + + let envContent = data + + const accessTokenLine = `ACCESS_TOKEN=${tokenObject.access_token}` + + if (envContent.includes('ACCESS_TOKEN=')) { + envContent = envContent.replace(/ACCESS_TOKEN=.*/g, accessTokenLine) + } else { + envContent += `\n${accessTokenLine}` + } + + await fs.writeFile(sourcePath, envContent) + + console.log('Access token updated successfully.') + } catch (err) { + console.warn('Could not update .env file. Token updated in memory only.') + } + + return tokenObject.access_token +} + +/** + * This function checks whether the existing access token is expired. + * If it is expired, it automatically generates a new one + * using the stored refresh token + */ + +async function checkAndRefreshAccessToken() { + const accessToken = config.ACCESS_TOKEN || process.env.ACCESS_TOKEN + const refreshToken = config.REFRESH_TOKEN || process.env.REFRESH_TOKEN + + if (!accessToken && refreshToken) { + await generateNewAccessToken() + return + } + + if (accessToken && isTokenExpired(accessToken)) { + console.log('Access token expired. Refreshing...') + await generateNewAccessToken() + } +} + +/** + * Retrieve a valid access token for use in API requests. + */ +async function getValidAccessToken() { + await checkAndRefreshAccessToken() + return process.env.ACCESS_TOKEN || config.ACCESS_TOKEN +} + +export default { + isTokenExpired, + generateNewAccessToken, + checkAndRefreshAccessToken, + getValidAccessToken +} \ No newline at end of file diff --git a/config/index.js b/config/index.js index 37ed712..6beb8a7 100644 --- a/config/index.js +++ b/config/index.js @@ -26,6 +26,9 @@ const config = { BOT_AGENT: process.env.BOT_AGENT ?? '', AUDIENCE: process.env.AUDIENCE ?? '', ISSUER_BASE_URL: process.env.ISSUER_BASE_URL ?? '', + ACCESS_TOKEN: process.env.ACCESS_TOKEN ?? '', + REFRESH_TOKEN: process.env.REFRESH_TOKEN ?? '', + RERUM_ACCESS_TOKEN_URL: process.env.RERUM_ACCESS_TOKEN_URL ?? '', BOT_TOKEN: process.env.BOT_TOKEN ?? '', PORT: parseInt(process.env.PORT ?? process.env.PORT_NUMBER ?? 3001, 10) } diff --git a/package-lock.json b/package-lock.json index c5f495e..fa9f7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express": "^5.2.1", "express-oauth2-jwt-bearer": "~1.7.1", "express-urlrewrite": "~2.0.3", + "jsonwebtoken": "^9.0.3", "mongodb": "^7.0.0", "morgan": "~1.10.1" }, @@ -1830,6 +1831,11 @@ "node": ">=20.19.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2318,6 +2324,14 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3902,6 +3916,57 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3932,6 +3997,41 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/package.json b/package.json index 5308875..239bbdd 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "express": "^5.2.1", "express-oauth2-jwt-bearer": "~1.7.1", "express-urlrewrite": "~2.0.3", + "jsonwebtoken": "^9.0.3", "mongodb": "^7.0.0", "morgan": "~1.10.1" },