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 <cursoragent@cursor.com>
This commit is contained in:
Joseph B. McQueen 2026-07-01 19:57:33 -04:00
parent 2984e8e851
commit 04fb4f6442

View file

@ -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');
});