Every app.get/post/delete handler used to live directly in index.js,
which turned the file into the routing layer, the state store, and
the wiring root all at once. This peels the handlers out into five
per-concern modules and leaves index.js as a small composition root.
routes/info.js
- GET /status
- GET /CollabCentral/:app/info
routes/auth.js
- GET /CollabCentral/:app/authUrl
- GET /CollabCentral/:app/oauth (owns SESSION_COOKIE_OPTIONS now)
routes/user.js
- GET /CollabCentral/:app/user/:scope/:action (groups list / find)
- POST /CollabCentral/:app/user/groups/{add,remove}
routes/admin.js
- GET/POST /CollabCentral/:app/admin/users
- DELETE /CollabCentral/:app/admin/users/:id
- Owns requireAdmin + adminUserRow + adminUsersList (private to
the module now that no other caller needs them).
routes/jobs.js
- POST /CollabCentral/:app/jobs/:action (edit / runNow / schedule /
cancel)
- GET /CollabCentral/:app/jobs/list/:scope
- GET /CollabCentral/:app/jobs/detail/:jobId
- Owns buildJob (moved from index.js) since nothing outside the
jobs routes ever called it.
Wiring pattern: each module exports registerXxxRoutes(app, deps)
and receives its dependencies through a dep-bag (isAuthorized,
isAdmin, webex, translator, jobsPipeline, saveConfig, helpers,
authorized, jobs, upload, sharp, uuid, fs, logger, ...). No route
module reaches into module-level state — that stays owned by
index.js.
index.js: 969 -> 457 lines (72% smaller than the original 1,642).
Now contains only imports, state loading (config, jobs, authorized,
tokens, botProfiles, groupsCache), the lib factory instantiations,
Express startup + cron schedules, the four register* calls, and a
handful of small helpers (saveConfig, cleanCompletedJobs,
isAuthorized, isAdmin, logger).
Tests still 53/53 green. Smoke-tested every route (auth matrix +
unknown bot + jobs detail 404 + signed-out 401 vs non-admin 403)
against a live process; every case matches pre-R3 behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
102 lines
4.6 KiB
JavaScript
102 lines
4.6 KiB
JavaScript
// OAuth endpoints.
|
|
// GET /CollabCentral/:app/authUrl — returns the Webex authorize URL
|
|
// the browser should redirect to.
|
|
// GET /CollabCentral/:app/oauth — Webex redirects the user back to
|
|
// this URL with a `code` query
|
|
// param; we exchange it for tokens
|
|
// and set the `id`+`displayName`
|
|
// session cookies.
|
|
//
|
|
// registerAuthRoutes(app, { buildAuthUrl, getOAuthRedirectUri, webex,
|
|
// env, logger })
|
|
|
|
// 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: false,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
maxAge: 24 * 60 * 60 * 1000
|
|
};
|
|
|
|
export function registerAuthRoutes(app, deps) {
|
|
var buildAuthUrl = deps.buildAuthUrl;
|
|
var getOAuthRedirectUri = deps.getOAuthRedirectUri;
|
|
var webex = deps.webex;
|
|
var env = deps.env || {};
|
|
var logger = deps.logger || function () {};
|
|
|
|
app.get('/CollabCentral/:app/authUrl', function (req, res) {
|
|
var url = buildAuthUrl(req.params.app);
|
|
if (!url) {
|
|
return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.');
|
|
}
|
|
res.send(url);
|
|
});
|
|
|
|
app.get('/CollabCentral/:app/oauth', async function (req, res) {
|
|
var appName = req.params.app;
|
|
var authCode = req.query.code;
|
|
|
|
if (!env.WEBEX_INTEGRATION_CLIENT_ID || !env.WEBEX_INTEGRATION_CLIENT_SECRET || !env.OAUTH_CALLBACK_URL_TEMPLATE) {
|
|
logger('oauth', 'Missing Webex integration env vars; cannot complete OAuth.');
|
|
return res.status(500).send('OAuth is not configured on the server.');
|
|
}
|
|
if (!authCode) {
|
|
return res.status(400).send('Missing OAuth `code` query parameter.');
|
|
}
|
|
|
|
var url = new URL('https://webexapis.com/v1/access_token');
|
|
var params = new URLSearchParams();
|
|
params.append('grant_type', 'authorization_code');
|
|
params.append('client_id', env.WEBEX_INTEGRATION_CLIENT_ID);
|
|
params.append('client_secret', env.WEBEX_INTEGRATION_CLIENT_SECRET);
|
|
params.append('code', authCode);
|
|
params.append('redirect_uri', getOAuthRedirectUri(appName));
|
|
|
|
var response;
|
|
try {
|
|
response = await webex.fetchWithRateLimit(url, { method: 'POST', body: params });
|
|
} catch (error) {
|
|
logger('oauth', 'Token exchange fetch failed: ' + (error && error.message || error));
|
|
return res.status(502).send('Failed to reach Webex to complete OAuth.');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
var errText = await response.text().catch(() => '');
|
|
logger('oauth', 'Token exchange returned ' + response.status + ' ' + response.statusText + ' ' + errText);
|
|
return res.status(401).send(response.statusText || 'OAuth token exchange failed.');
|
|
}
|
|
|
|
var jsonData = await response.json();
|
|
var whoami;
|
|
try {
|
|
whoami = await webex.whoAmI(jsonData.access_token);
|
|
} catch (err) {
|
|
logger('oauth', 'whoAmI failed: ' + (err && err.message || err));
|
|
return res.status(502).send('Failed to read your Webex profile after OAuth.');
|
|
}
|
|
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('id', whoami.id, SESSION_COOKIE_OPTIONS)
|
|
.cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS)
|
|
.redirect(301, '/CollabCentral/' + appName + '/sendMessage.html');
|
|
});
|
|
}
|