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
3 changes: 2 additions & 1 deletion auth/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auth } from 'express-oauth2-jwt-bearer'
import config from '../config/index.js'
import tokenRefreshMiddleware from './token-refresh-middleware.js'

const _tokenError = function (err, req, res, next) {
if(!err.code || err.code !== "invalid_token"){
Expand Down Expand Up @@ -42,7 +43,7 @@ const _extractUser = (req, res, next) => {
* // do authorized things
* });
*/
const checkJwt = [READONLY, auth(), _tokenError, _extractUser]
const checkJwt = [READONLY, tokenRefreshMiddleware, auth(), _tokenError, _extractUser]

/**
* Public API proxy to generate new access tokens through Auth0
Expand Down
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'

// 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
}
28 changes: 28 additions & 0 deletions auth/token-refresh-middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@

/**
* Middleware to automatically check and refresh the server's Auth0/RERUM access token
* before processing authenticated operations. Uses the existing token manager to
* preserve the Auth0 refresh-token workflow.
*
* This runs before client JWT validation to ensure the backend's token is fresh
* for any internal authenticated calls.
*/

import tokenManager from './token-manager.js'

/**
* Middleware function to refresh the server's access token if expired.
* Logs errors but does not fail the request to avoid blocking client operations.
*/
const tokenRefreshMiddleware = async (req, res, next) => {
try {
await tokenManager.checkAndRefreshAccessToken()
next()
} catch (error) {
console.error('Server token refresh failed:', error.message)
// Continue processing the request even if refresh fails
next()
}
}

export default tokenRefreshMiddleware
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",
"mongodb": "^7.0.0",
"morgan": "~1.10.1"
},
Expand Down
2 changes: 1 addition & 1 deletion routes/__tests__/history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ it("'/history/:id' route functions", async () => {
expect(response.headers["allow"]).toBeTruthy()
expect(response.headers["link"]).toBeTruthy()
expect(Array.isArray(response.body)).toBe(true)
})
}, 20000)
2 changes: 1 addition & 1 deletion routes/__tests__/idNegotiation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ it("Functional '@id-id' negotiation on objects returned.", async () => {
expect(nonegotiate["@id"]).toBe(`${process.env.RERUM_ID_PREFIX}example`)
expect(nonegotiate.id).toBe("test_example")
expect(nonegotiate.test).toBe("item")
})
}, 20000)
2 changes: 1 addition & 1 deletion routes/__tests__/patch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ it("'/patch' route functions", async () => {
expect(response.headers["allow"]).toBeTruthy()
expect(response.headers["link"]).toBeTruthy()

})
}, 20000)
2 changes: 1 addition & 1 deletion routes/__tests__/unset.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ it("'/unset' route functions", async () => {
expect(response.headers["etag"]).toBeTruthy()
expect(response.headers["allow"]).toBeTruthy()
expect(response.headers["link"]).toBeTruthy()
})
}, 20000)