DC-046 DC-047 pluggable auth providers + email magic link
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:

  * TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
    as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
    back-compat; new /api/v1/auth/login/totp/* routes use the new shape.

  * EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
    base64url token, stores its SHA-256 hash in data/email-tokens.json
    (atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
    nodemailer if providers.email.{host,port,username,password} is set OR
    falls back to log.info('auth', 'email magic link issued', ...) for dev.
    Verify accepts the token, marks it used, creates the same DashCaddy
    session cookie that TOTP uses (single global cookie model).

createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).

Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.

Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.

New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.

Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).

DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
This commit is contained in:
Hermes Agent
2026-07-20 01:40:33 -07:00
parent 894e091335
commit c619d3a36b
14 changed files with 1793 additions and 15 deletions
+70 -1
View File
@@ -3,6 +3,8 @@ const initTotp = require('./totp');
const initKeys = require('./keys');
const initSessionHandlers = require('./session-handlers');
const initSsoGate = require('./sso-gate');
const initLogin = require('./login');
const { createAuthProviderRegistry } = require('../../src/auth/providers');
/**
* Auth routes aggregator
@@ -10,6 +12,30 @@ const initSsoGate = require('./sso-gate');
* @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();
@@ -28,11 +54,54 @@ module.exports = function(ctx) {
getServiceById: ctx.getServiceById,
licenseManager: ctx.licenseManager,
servicesStateManager: ctx.servicesStateManager,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken
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: ctx.platformPaths || null,
};
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: true } },
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,
},
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 }));
+110
View File
@@ -0,0 +1,110 @@
/**
* Pluggable auth routes — DC-046.
*
* Mount: /api/v1/auth (under the existing apiRouter prefix).
*
* Endpoints:
* GET /login/methods — list enabled providers + their login methods
* (no secrets). Drives the login UI button list.
*
* POST /login/:provider/initiate — start the auth flow for `provider`
* using its default method. The :provider
* segment maps to a registered AuthProvider
* (see src/auth/providers/index.js).
*
* POST /login/:provider/verify — complete the auth flow. Sets the
* DashCaddy session cookie on success.
*
* GET /login/recovery-info — generic lockout-info UI (delegates to
* the first enabled provider's
* recoveryInfo(); falls back to a static
* "no providers enabled" message).
*
* POST /disable/:provider — turn off a provider (e.g. /api/v1/auth/disable/totp).
* Provider may require re-verification.
*
* The legacy /api/v1/totp/* endpoints (mount: src/app.js → authRoutes →
* routes/auth/totp.js) are kept as thin pass-throughs to the TOTP provider
* so old frontends keep working. New frontends should use this namespace.
*/
const express = require('express');
const { ValidationError, NotFoundError } = require('../../src/utilities/errors');
const { ok } = require('../../src/utils/responses');
/**
* Factory — wires the registry into the router.
*
* @param {Object} deps
* @param {Object} deps.registry createAuthProviderRegistry() result
* @param {Function} deps.asyncHandler
* @param {Function} deps.errorResponse
* @param {Object} deps.log
* @returns {express.Router}
*/
module.exports = function({ registry, asyncHandler, errorResponse, log }) {
const router = express.Router();
// List all enabled providers + their login methods.
router.get('/login/methods', asyncHandler(async (_req, res) => {
const enabled = await registry.listEnabled();
ok(res, { providers: enabled });
}, 'auth-methods-list'));
// Initiate a provider's auth flow. The :provider segment selects which
// AuthProvider from the registry. methodId is optional — providers may
// pick their default method if omitted (TOTP does this).
router.post('/login/:provider/initiate', asyncHandler(async (req, res) => {
const provider = registry.getProvider(req.params.provider);
if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`);
const methodId = req.body?.methodId || (await provider.listMethods())[0]?.id;
if (!methodId) throw new ValidationError('No methodId provided and provider has no methods', 'methodId');
if (!(await provider.isEnabled())) {
throw new ValidationError(`Provider ${req.params.provider} is not enabled`, 'provider');
}
log.debug('auth', 'provider initiate', { provider: req.params.provider, methodId });
return provider.initiate(methodId, req, res);
}, 'auth-initiate'));
// Verify a provider's auth flow. On success the provider creates the
// DashCaddy session cookie (same cookie across all providers).
router.post('/login/:provider/verify', asyncHandler(async (req, res) => {
const provider = registry.getProvider(req.params.provider);
if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`);
const methodId = req.body?.methodId || (await provider.listMethods())[0]?.id;
if (!methodId) throw new ValidationError('No methodId provided and provider has no methods', 'methodId');
log.debug('auth', 'provider verify', { provider: req.params.provider, methodId });
return provider.verify(methodId, req, res);
}, 'auth-verify'));
// Generic lockout-recovery info. Today this delegates to TOTP (the only
// provider). When email magic link lands, it can return its own recovery
// shape and the UI will switch.
router.get('/login/recovery-info', asyncHandler(async (_req, res) => {
const totp = registry.getProvider('totp');
if (totp) {
const info = await totp.recoveryInfo();
return ok(res, info);
}
ok(res, {
status: 'not_configured',
isSetUp: false,
hint: 'No auth providers are configured on this server yet.',
});
}, 'auth-recovery-info'));
// Disable a provider.
router.post('/disable/:provider', asyncHandler(async (req, res) => {
const provider = registry.getProvider(req.params.provider);
if (!provider) throw new NotFoundError(`Unknown auth provider: ${req.params.provider}`);
log.info('auth', 'provider disable', { provider: req.params.provider });
return provider.disable(req, res);
}, 'auth-disable'));
return router;
};