Skip to content

Commit 121111d

Browse files
committed
feat(auth): auto-refresh OIDC tokens when session is empty
When the OIDC JWT strategy finds no usable id_token (missing from both the session and the openid_id_token cookie) but a refreshToken cookie exists, transparently call the IdP's refresh-token endpoint, repopulate the session, and continue the request. This makes in-memory session loss invisible to the user — primarily addressing the deployment-restart scenario where the MemoryStore is wiped while the browser still holds a valid Bearer token, which previously caused empty 'Authorization: Bearer ' headers to reach downstream services like LiteLLM. If refresh is impossible (no refresh cookie, IdP rejects, etc.), the strategy fails closed with 401 so the SPA's existing axios interceptor handles it. - Add refreshOpenIDTokensFromCookie(req, res, userId) helper in AuthService.js with module-private dedup map keyed on the refresh-token value, so concurrent requests for the same credential share one IdP round-trip. - Wire helper into openIdJwtStrategy.js: detect missing id_token before populating user.federatedTokens, refresh if possible, fail closed otherwise. - Lazy-require getOpenIdConfig to avoid pulling undici/openid-client into unrelated AuthService consumers' module-init. - Tests: 11 new helper tests (no-cookie short-circuit, IdP success/failure paths, getOpenIdConfig throws, missing res, concurrent dedup, sequential release, failure release) + 4 new strategy tests (refresh on empty session, 401 on helper failure, no refresh when not needed, no refresh cookie). - Updates the existing 'should set id_token to undefined' strategy test to reflect the new fail-closed behavior.
1 parent d3c8add commit 121111d

4 files changed

Lines changed: 581 additions & 7 deletions

File tree

api/server/services/AuthService.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ const {
3939
const { registerSchema } = require('~/strategies/validators');
4040
const { getAppConfig } = require('~/server/services/Config');
4141
const { sendEmail } = require('~/server/utils');
42+
const cookies = require('cookie');
43+
const openIdClient = require('openid-client');
4244

4345
const domains = {
4446
client: process.env.DOMAIN_CLIENT,
@@ -714,6 +716,90 @@ const setOpenIDAuthTokens = (
714716
}
715717
};
716718

719+
/**
720+
* Module-private map for deduplicating concurrent token-refresh calls.
721+
* Key: refresh token value. Value: in-flight refresh promise.
722+
*
723+
* Within a single Node process, two requests that both observe an empty
724+
* session for the same refresh token will share one IdP round-trip,
725+
* preventing a refresh-token-rotation race.
726+
*
727+
* @type {Map<string, Promise<object | null>>}
728+
*/
729+
const inFlightRefreshes = new Map();
730+
731+
/**
732+
* Refresh OpenID tokens server-side using the persistent `refreshToken` cookie.
733+
* Used to recover from session loss (e.g. in-memory session store wiped by a
734+
* deployment) without forcing the user to re-authenticate.
735+
*
736+
* @param {import('express').Request} req
737+
* @param {import('express').Response} res
738+
* @param {string} userId - MongoDB user id, for the openid_user_id cookie.
739+
* @returns {Promise<object | null>} The new tokenset on success, or null on any
740+
* failure (no cookie, IdP error, missing res, etc.). Caller treats null as
741+
* "could not refresh — fail closed."
742+
*/
743+
const refreshOpenIDTokensFromCookie = async (req, res, userId) => {
744+
if (!res) {
745+
logger.warn(
746+
'[refreshOpenIDTokensFromCookie] No res object available; cannot persist rotated cookies',
747+
);
748+
return null;
749+
}
750+
751+
const cookieHeader = req?.headers?.cookie;
752+
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
753+
const refreshToken = parsedCookies.refreshToken;
754+
if (!refreshToken) {
755+
return null;
756+
}
757+
758+
const existing = inFlightRefreshes.get(refreshToken);
759+
if (existing) {
760+
return existing;
761+
}
762+
763+
const refreshPromise = (async () => {
764+
let openIdConfig;
765+
try {
766+
// Lazy-require to avoid pulling undici/openid-client into all consumers
767+
// of AuthService at module-init time (those imports break Node 18 tests
768+
// that don't need them).
769+
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
770+
openIdConfig = getOpenIdConfig();
771+
} catch (err) {
772+
logger.warn('[refreshOpenIDTokensFromCookie] getOpenIdConfig failed', err);
773+
return null;
774+
}
775+
776+
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
777+
778+
let tokenset;
779+
try {
780+
tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
781+
} catch (err) {
782+
logger.warn('[refreshOpenIDTokensFromCookie] refreshTokenGrant failed', err);
783+
return null;
784+
}
785+
786+
const appAuthToken = setOpenIDAuthTokens(tokenset, req, res, userId, refreshToken);
787+
if (!appAuthToken) {
788+
logger.warn('[refreshOpenIDTokensFromCookie] setOpenIDAuthTokens returned no token');
789+
return null;
790+
}
791+
792+
return tokenset;
793+
})();
794+
795+
inFlightRefreshes.set(refreshToken, refreshPromise);
796+
try {
797+
return await refreshPromise;
798+
} finally {
799+
inFlightRefreshes.delete(refreshToken);
800+
}
801+
};
802+
717803
/**
718804
* Resend Verification Email
719805
* @param {Object} req
@@ -783,4 +869,5 @@ module.exports = {
783869
setCloudFrontAuthCookies,
784870
requestPasswordReset,
785871
resendVerificationEmail,
872+
refreshOpenIDTokensFromCookie,
786873
};

0 commit comments

Comments
 (0)