Files
dashcaddy/dashcaddy-api/routes/auth/index.js
T
Hermes 5add962178
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix(auth): /auth/me hotfix — isValid not isSessionValid + guard (DC-093 r2)
Live verification of 4125d7a caught a 500 every 60s: the handler called
deps.session.isSessionValid(req), but the production session context
(src/context/session.js) exposes isValid — isSessionValid is only the
middleware-internal name. The round-1 test stub mirrored the wrong
name, so tests passed while prod 500'd (stub-shape-fits-bug).

- routes/auth/index.js: precompute guarded _authed (typeof isValid ===
  'function' check); malformed session object can no longer 500 a
  60s-polled endpoint. Fallback true: handler runs only after the
  session middleware admitted the request.
- routes/auth/admin.js:119: same latent 500 fixed (isSessionValid →
  isValid) — pre-existing DC-048 bug, any legacy-session /me call.
- Test stub now carries the real shape {isValid} with isSessionValid
  deliberately absent — regression to the wrong name now fails tests.

Judge: GLM-5.3 cold read round 2, grade B ship; stale-comment polish
folded in. Full suite 115/2682 green.
2026-08-22 19:06:34 -07:00

231 lines
9.3 KiB
JavaScript

const express = require('express');
const initTotp = require('./totp');
const initKeys = require('./keys');
const initSessionHandlers = require('./session-handlers');
const initSsoGate = require('./sso-gate');
const initLogin = require('./login');
const initAdmin = require('./admin');
const { createAuthProviderRegistry } = require('../../src/auth/providers');
const { createUserStore } = require('../../src/security/user-store');
const { ok } = require('../../src/utils/responses');
/**
* Auth routes aggregator
* Assembles all auth sub-routes with their dependencies
* @param {Object} ctx - Application context (for backward compatibility)
* @returns {express.Router}
*/
/**
* Pull the SMTP/email provider config from whichever source has it.
*
* Resolution order:
* 1. ctx.emailProviderConfig — explicit override (operator or env)
* 2. ctx.notification.getConfig?.().providers.email — reuse the same
* SMTP settings notifications use. This is the "magic" — operators
* configure SMTP once for system notifications and email-auth picks
* it up automatically.
* 3. null — provider will operate in dev-console fallback mode.
*/
function _extractEmailConfig(ctx) {
if (ctx.emailProviderConfig && typeof ctx.emailProviderConfig === 'object') {
return ctx.emailProviderConfig;
}
const n = ctx.notification;
if (n && typeof n.getConfig === 'function') {
const cfg = n.getConfig();
if (cfg && cfg.providers && cfg.providers.email) return cfg.providers.email;
}
return null;
}
module.exports = function(ctx) {
const router = express.Router();
// DC-048: opt-in user store. Only instantiated when the operator has
// explicitly enabled email auth in siteConfig. The default for new
// installs is "no user-store, no allowlist, no admin invites" — the
// legacy single-user TOTP flow. Operators who turn email auth on
// (siteConfig.authProviders.email.enabled = true) opt into multi-user.
// Once opted in, the first email to log in is the bootstrap admin.
const platformPaths = ctx.platformPaths || require('../../platform-paths');
let userStore = null;
const _emailExplicitlyEnabled =
ctx.siteConfig &&
ctx.siteConfig.authProviders &&
ctx.siteConfig.authProviders.email &&
ctx.siteConfig.authProviders.email.enabled === true;
if (_emailExplicitlyEnabled) {
userStore = createUserStore({
dataDir: platformPaths.dataDir,
log: ctx.log,
});
ctx.userStore = userStore;
ctx.log && ctx.log.info && ctx.log.info('user', 'multi-user mode enabled (email auth on)');
} else {
ctx.log && ctx.log.info && ctx.log.info('user', 'single-user mode (email auth not enabled — set siteConfig.authProviders.email.enabled = true to opt into multi-user)');
}
// Extract dependencies from context
const deps = {
authManager: ctx.authManager,
credentialManager: ctx.credentialManager,
totpConfig: ctx.totpConfig,
saveTotpConfig: ctx.saveTotpConfig,
session: ctx.session,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
// Additional deps for sso-gate
fetchT: ctx.fetchT,
getServiceById: ctx.getServiceById,
licenseManager: ctx.licenseManager,
servicesStateManager: ctx.servicesStateManager,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
// For DC-046 pluggable auth providers (EmailMagicLink, OIDC, …).
// Pass-through — providers like the EmailMagicLinkProvider need
// notificationManager for SMTP delivery, plus the siteConfig for
// building verification links.
notificationManager: ctx.notification,
siteConfig: ctx.siteConfig,
// DC-047: data-directory resolution for the email-token JSON store.
platformPaths,
// DC-048: user store for allowlist + bootstrap. Null when email
// auth is disabled — providers fall back to "allow everyone" legacy
// behavior (DC-046/047 semantics).
userStore,
};
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
// DC-046: pluggable auth provider registry. The TOTP provider is wired
// here against the existing totpConfig / saveTotpConfig objects so it
// behaves identically to the legacy /api/v1/totp/* routes mounted below.
const registry = createAuthProviderRegistry(
{
credentialManager: ctx.credentialManager,
session: ctx.session,
saveTotpConfig: ctx.saveTotpConfig,
config: { totp: ctx.totpConfig, email: ctx.emailProviderConfig || { enabled: false } },
log: ctx.log,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken,
// DC-047: EmailMagicLinkProvider needs SMTP config + a public URL
// resolver + the data dir for the token store. All three come from
// existing global config — no new config knobs required.
emailConfig: _extractEmailConfig(ctx),
siteConfig: ctx.siteConfig || {},
platformPaths: deps.platformPaths,
// DC-048: user store shared by every provider for allowlist checks
// and the bootstrap-admin-on-first-login rule.
userStore: deps.userStore,
// DC-052: license manager so providers can gate Pro-only flows
// (e.g. magic-link signup that crosses the 3-user cap).
licenseManager: ctx.licenseManager,
},
ctx.siteConfig
);
ctx.authProviders = registry; // exposed for /api/v1/auth/methods, etc.
// NEW (DC-046): pluggable /api/v1/auth/login/* routes. Frontends should
// migrate here over time — the legacy /api/v1/totp/* routes below stay
// for back-compat. Mounted under `/auth` so internal paths
// (`/login/methods`, `/disable/:provider`) resolve at the canonical
// `/api/v1/auth/login/*` and `/api/v1/auth/disable/*` URLs that match
// PUBLIC_ROUTES and the documented login UI contract.
router.use('/auth', initLogin({
registry,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
}));
router.use(initTotp(deps));
router.use(initKeys(deps));
router.use(initSsoGate({ ...deps, getAppSession, appSessionCache }));
// DC-093: /auth/me is ALWAYS mounted — even on single-user installs where
// the rest of the admin router is not. The frontend admin panel polls
// /api/v1/auth/me on every dashboard load (and re-probes every 60s while
// unauthenticated), so leaving the route unmounted meant every single-user
// install logged a DC-404 ERROR + stack at 1/min per open tab — for years
// of tab-time. The response mirrors the mounted /me shape (routes/auth/
// admin.js) and adds `mode` so clients can distinguish "multi-user with
// this identity" from "single-user install" without guessing from a 404.
// It sits behind the standard session middleware (NOT in PUBLIC_ROUTES),
// so unauthenticated probes get a clean 401, never this handler.
router.get('/auth/me', deps.asyncHandler(async (req, res) => {
// Defense-in-depth: the production session context exposes isValid()
// (src/context/session.js). If a future refactor passes a differently-
// shaped object, fall back to authenticated:true rather than throwing
// a 500 — /me is polled by every open dashboard tab every 60s, so a
// throw here becomes a log storm (exactly what DC-093 removed), and
// this handler only runs after the session middleware already
// admitted the request, so default-false would misreport a valid
// session as unauthenticated.
const _authed = deps.session && typeof deps.session.isValid === 'function'
? deps.session.isValid(req)
: true;
if (userStore && req.user && req.user.id) {
const stored = await userStore.getUser(req.user.id);
return ok(res, {
user: stored
? {
id: stored.id,
email: stored.email,
displayName: stored.displayName,
role: stored.role,
isAdmin: stored.role === 'admin',
createdAt: stored.createdAt,
lastLoginAt: stored.lastLoginAt,
loginCount: stored.loginCount,
}
: null,
authenticated: _authed,
mode: 'multi',
});
}
// No user store mounted → single-user install. The operator who
// unlocked TOTP IS the admin (there is no other identity).
return ok(res, {
user: null,
authenticated: _authed,
role: 'admin',
isAdmin: true,
legacy: true,
mode: 'single',
});
}, 'auth-me-mode'));
// DC-048: mount admin routes ONLY when the user-store was instantiated
// (i.e. email auth is enabled). Single-user installs don't see
// /admin/* or /invites/* at all — /me above is the one exception.
if (userStore) {
// DC-052: pass licenseManager + userStore through so the tier-gate
// middleware can read them. Both are optional — the gate short-
// circuits when licenseManager is absent.
const adminRouter = initAdmin({
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
session: ctx.session,
licenseManager: ctx.licenseManager,
userStore,
});
// DC-048 attach: licenseManager + userStore on app.locals
if (ctx.licenseManager || userStore) {
router.use('/auth', (req, _res, next) => {
if (ctx.licenseManager) req.app.locals.licenseManager = ctx.licenseManager;
if (userStore) req.app.locals.userStore = userStore;
next();
});
}
router.use('/auth', adminRouter);
}
return router;
};