-
Notifications
You must be signed in to change notification settings - Fork 0
Created backend token manager for automatic access token refresh #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against malformed tokens 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We treat the token as expired exactly at |
||
| } 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() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| // 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This module mutates |
||
|
|
||
| // 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() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ | |
| "express": "^5.2.1", | ||
| "express-oauth2-jwt-bearer": "~1.7.1", | ||
| "express-urlrewrite": "~2.0.3", | ||
| "jsonwebtoken": "^9.0.3", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| "mongodb": "^7.0.0", | ||
| "morgan": "~1.10.1" | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
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?