From 04fb4f64422066ad2d1af5492fc5ca88b70893ab Mon Sep 17 00:00:00 2001 From: "Joseph B. McQueen" Date: Wed, 1 Jul 2026 19:57:33 -0400 Subject: [PATCH] Fix OAuth infinite-redirect loop caused by httpOnly session cookies The bug: SESSION_COOKIE_OPTIONS had httpOnly: true, so js/app.js could not see the `id` cookie set by /oauth. Every page load thought the user was signed out, redirected to Webex, minted a fresh token, set another invisible cookie, redirected back, and looped -- until Webex's Common Token Store hit its per-user limit and returned error=tokenlimit_reached on the next callback. Fix: - httpOnly is now explicitly false with a comment explaining why: the app's design has always relied on the client reading the id and displayName cookies via document.cookie. - sameSite tightened change from 'strict' to 'lax' so the cookie reliably survives the webex.com -> /oauth -> /sendMessage.html redirect chain across all browsers (some treat continuations of a cross-site navigation as cross-site for strict cookies). - Stop setting access_token, refresh_token, avatar, email, orgId on the response. `req.cookies.*` grep confirms the server never reads any of them, and the client uses id/displayName only. Removing the token cookies also eliminates a would-be XSS foothold. Existing broken sessions: setting a new cookie with the same name and path replaces the old one regardless of httpOnly flag, so a single completed OAuth after this deploy repairs the browser state. Co-authored-by: Cursor --- index.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index 13531af..e76af45 100644 --- a/index.js +++ b/index.js @@ -415,11 +415,21 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { } else { res.status(401) } }) -// Standard options for the session cookies set after a successful OAuth round-trip. +// Options for the session cookies set after a successful OAuth round-trip. +// +// httpOnly is deliberately false: js/app.js reads `id` (to decide whether to +// redirect to OAuth) and `displayName` (to render the user chip in the header) +// via document.cookie. Enabling httpOnly here hides the cookies from JS, which +// causes an infinite auth loop where every page load thinks the user is not +// signed in and kicks off a fresh OAuth exchange — burning Webex tokens until +// the CTS token limit is reached. +// +// sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth +// redirect chain webexapis.com -> /oauth -> /sendMessage.html on all browsers. const SESSION_COOKIE_OPTIONS = { - httpOnly: true, + httpOnly: false, secure: true, - sameSite: 'strict', + sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 }; @@ -467,14 +477,14 @@ app.get(`/CollabCentral/:app/oauth`, async function (req, res) { } logger("oauth", whoami.displayName + " successfully authed for " + appName + "."); + // Only the two cookies that the server (req.cookies.id, .displayName) and + // the client (getCookie('id'), getCookie('displayName')) actually read. + // Access/refresh tokens deliberately stay out of the browser: they never + // need to leave the server, and putting them in cookies would expose them + // to any XSS that might slip in later. res - .cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS) .cookie('id', whoami.id, SESSION_COOKIE_OPTIONS) - .cookie('avatar', whoami.avatar, SESSION_COOKIE_OPTIONS) - .cookie('email', whoami.userName, SESSION_COOKIE_OPTIONS) - .cookie('orgId', whoami.orgId, SESSION_COOKIE_OPTIONS) - .cookie('access_token', jsonData.access_token, SESSION_COOKIE_OPTIONS) - .cookie('refresh_token', jsonData.refresh_token, SESSION_COOKIE_OPTIONS) + .cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS) .redirect(301, '/CollabCentral/' + appName + '/sendMessage.html'); });