Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
135 changes: 135 additions & 0 deletions auth/token-manager.js
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"sourcePath" is hard-coded to .env relative to the process CWD. In some deployment setups (docker) this might not point to the right file or might silently fail. Should we either
(a) make this path configurable
(e.g. process.env.ENV_FILE_PATH ?? '.env'`), or
(b) clearly document that this is intended as a local-dev convenience only?


// 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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Guard against malformed tokens
It may be safer to add a check to ensure the token has the expected JWT structure before attempting to decode it.

if (parts.length !== 3) return true;

This prevents errors if the token is malformed or not actually a JWT.

Also, consider using jsonwebtoken.decode() instead of manually decoding with Buffer.from(...), since it handles Base64URL encoding and parsing more robustly.

)

return !payload.exp || Date.now() >= payload.exp * 1000
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We treat the token as expired exactly at exp. To be more robust to small clock skew between our server and the issuer, we might want to treat it as expired slightly before, e.g. subtracting 30 seconds from exp before comparing.

} 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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the token endpoint responds with non‑JSON (e.g. an HTML error page), response.json() will throw before we can check response.ok. Consider wrapping the json() call in a try/catch and surfacing a clearer error message (including HTTP status) when parsing fails.


// 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This module mutates process.env at runtime, which is convenient but can be surprising if other parts of the app read env vars only at startup. Could we document this behavior prominently, and/or consider keeping the token in a module‑level variable and having callers always use getValidAccessToken() instead of reading from process.env directly?


// 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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With multiple concurrent requests, it’s possible that several callers all detect an expired token and call generateNewAccessToken() at the same time. Not a correctness bug, but it could result in unnecessary token refresh calls. Consider adding a simple in‑flight promise lock so that only one refresh happens at a time and others await it.

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
}
3 changes: 3 additions & 0 deletions config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
100 changes: 100 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"express": "^5.2.1",
"express-oauth2-jwt-bearer": "~1.7.1",
"express-urlrewrite": "~2.0.3",
"jsonwebtoken": "^9.0.3",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We add "jsonwebtoken "as a dependency but don’t appear to use it in "token-manager". Do we plan to use it for decoding/verifying tokens here? If not, maybe we can either (
a) use it for "isTokenExpired" instead of manual parsing, or
(b) drop the dependency for now to keep the surface area smaller.

"mongodb": "^7.0.0",
"morgan": "~1.10.1"
},
Expand Down