mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +00:00
b329916a0c
Second slice of Phase 4. Pulls the three auth-family route groups out of
index.ts into focused routers. All handlers move verbatim; index.ts drops
~845 lines.
New route files:
- routes/auth.ts: /api/auth core (status, setup, login, password, logout,
check, generate-node-token)
- routes/mfa.ts: /api/auth/login/mfa + full /api/auth/mfa/* surface
(status, enroll start/confirm, disable, backup-codes/regenerate,
sso-bypass)
- routes/sso.ts: /api/auth/sso/{providers,ldap,oidc/:provider/authorize,
oidc/:provider/callback} + getSSOBaseUrl helper. Module-load calls
SSOService.getInstance().seedFromEnv() so env-seeded providers are
available on the first request.
Shared lifts:
- helpers/constants.ts: MFA_REPLAY_TTL_MS + MFA_REPLAY_PURGE_INTERVAL_MS
(used by mfa.ts and the startup purge timer in index.ts) and
BCRYPT_SALT_ROUNDS (shared between setup and password-change handlers).
- middleware/auth.ts: new reissueSessionAfterTokenBump(req, res, userId)
helper collapses three copies of "bump → fetch user → re-sign cookie"
across auth.ts (password change) and mfa.ts (enrol confirm, disable).
Code review fixes:
- File-local requireEnrolledMfaUser helper in mfa.ts eliminates four
copies of "auth check + rejectApiTokenScope + load enrolled MFA" with
near-identical shape.
- Applied BCRYPT_SALT_ROUNDS to auth.ts setup + password handlers.
Mount order in index.ts: authRouter / mfaRouter / ssoRouter sit before
authGate because login / setup / SSO-callback are public; handlers that
need auth use authMiddleware directly on the route.
213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { DatabaseService, type AuthProvider } from '../services/DatabaseService';
|
|
import { SSOService } from '../services/SSOService';
|
|
import { CryptoService } from '../services/CryptoService';
|
|
import { issueSessionCookie, issueMfaPendingCookie } from '../middleware/auth';
|
|
import { authRateLimiter, ssoRateLimiter } from '../middleware/rateLimiters';
|
|
import { isSecureRequest } from '../helpers/cookies';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
// Seed SSO config from environment variables on module load. One-shot side
|
|
// effect at startup; safe to repeat (upsert).
|
|
SSOService.getInstance().seedFromEnv();
|
|
|
|
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request
|
|
* Host header, with injection validation. */
|
|
function getSSOBaseUrl(req: Request, res: Response): string | null {
|
|
const host = req.get('host') || '';
|
|
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
|
|
console.error('[SSO] Rejected suspicious Host header');
|
|
res.redirect('/?sso_error=Invalid+request');
|
|
return null;
|
|
}
|
|
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
|
|
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
|
|
}
|
|
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
|
|
}
|
|
|
|
export const ssoRouter = Router();
|
|
|
|
ssoRouter.get('/providers', (_req: Request, res: Response): void => {
|
|
try {
|
|
const providers = SSOService.getInstance().getEnabledProviders();
|
|
res.json(providers);
|
|
} catch (e) {
|
|
console.warn('[SSO] Failed to list enabled providers, returning empty list:', getErrorMessage(e, 'unknown'));
|
|
res.json([]);
|
|
}
|
|
});
|
|
|
|
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) {
|
|
res.status(400).json({ error: 'Username and password are required' });
|
|
return;
|
|
}
|
|
|
|
const result = await SSOService.getInstance().authenticateLDAP(username, password);
|
|
if (!result.success || !result.user) {
|
|
res.status(401).json({ error: result.error || 'Authentication failed' });
|
|
return;
|
|
}
|
|
|
|
const user = SSOService.getInstance().provisionUser({
|
|
authProvider: 'ldap',
|
|
providerId: result.user.providerId,
|
|
preferredUsername: result.user.preferredUsername,
|
|
email: result.user.email,
|
|
role: result.user.role,
|
|
});
|
|
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
|
|
// If MFA is enabled AND the user has opted into SSO enforcement, route
|
|
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
|
|
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
|
}
|
|
if (mfa?.enabled && mfa.sso_enforce_mfa) {
|
|
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
|
|
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
|
|
res.json({ success: true, mfaRequired: true });
|
|
return;
|
|
}
|
|
|
|
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
|
|
console.log(`[SSO] LDAP login successful: ${user.username}`);
|
|
res.json({ success: true, message: 'Login successful' });
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'LDAP login failed');
|
|
console.error('[SSO] LDAP login error:', msg);
|
|
res.status(500).json({ error: msg });
|
|
}
|
|
});
|
|
|
|
ssoRouter.get('/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const provider = String(req.params.provider);
|
|
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
|
|
if (!validProviders.includes(provider)) {
|
|
res.status(400).json({ error: 'Invalid SSO provider' });
|
|
return;
|
|
}
|
|
|
|
const baseUrl = getSSOBaseUrl(req, res);
|
|
if (!baseUrl) return;
|
|
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
|
|
|
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
|
|
|
|
// Store state + codeVerifier in an encrypted short-lived cookie.
|
|
const cryptoSvc = CryptoService.getInstance();
|
|
const statePayload = JSON.stringify({ state, codeVerifier, provider });
|
|
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
|
|
httpOnly: true,
|
|
secure: isSecureRequest(req),
|
|
sameSite: 'lax', // Must be lax for cross-site IdP redirect
|
|
maxAge: 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
|
|
res.redirect(url);
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'SSO initialization failed');
|
|
console.error('[SSO] OIDC authorize error:', msg);
|
|
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
|
}
|
|
});
|
|
|
|
ssoRouter.get('/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const provider = String(req.params.provider);
|
|
const code = String(req.query.code || '');
|
|
const state = String(req.query.state || '');
|
|
const oidcError = req.query.error ? String(req.query.error) : '';
|
|
const error_description = req.query.error_description ? String(req.query.error_description) : '';
|
|
|
|
if (oidcError) {
|
|
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
|
|
return;
|
|
}
|
|
|
|
if (!code || !state) {
|
|
res.redirect('/?sso_error=Missing+authorization+code');
|
|
return;
|
|
}
|
|
|
|
const stateCookie = req.cookies?.sencho_sso_state;
|
|
// Always clear the one-time state cookie, regardless of outcome.
|
|
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
|
|
if (!stateCookie) {
|
|
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
|
|
return;
|
|
}
|
|
|
|
const cryptoSvc = CryptoService.getInstance();
|
|
let statePayload: { state: string; codeVerifier: string; provider: string };
|
|
try {
|
|
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
|
|
} catch (e) {
|
|
console.error('[SSO] Failed to decrypt SSO state cookie:', getErrorMessage(e, 'unknown'));
|
|
res.redirect('/?sso_error=Invalid+SSO+session');
|
|
return;
|
|
}
|
|
|
|
if (statePayload.provider !== provider) {
|
|
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
|
|
return;
|
|
}
|
|
|
|
const baseUrl = getSSOBaseUrl(req, res);
|
|
if (!baseUrl) return;
|
|
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
|
|
|
const result = await SSOService.getInstance().handleOIDCCallback(
|
|
provider, callbackUrl,
|
|
{ code, state },
|
|
statePayload.state,
|
|
statePayload.codeVerifier,
|
|
);
|
|
|
|
if (!result.success || !result.user) {
|
|
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
|
|
return;
|
|
}
|
|
|
|
const user = SSOService.getInstance().provisionUser({
|
|
authProvider: provider as AuthProvider,
|
|
providerId: result.user.providerId,
|
|
preferredUsername: result.user.preferredUsername,
|
|
email: result.user.email,
|
|
role: result.user.role,
|
|
});
|
|
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
|
|
// If MFA is enabled AND the user has opted into SSO enforcement, set only
|
|
// the partial-auth cookie. The frontend surfaces the challenge screen
|
|
// based on `/api/auth/status` after the redirect lands.
|
|
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
|
}
|
|
if (mfa?.enabled && mfa.sso_enforce_mfa) {
|
|
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
|
|
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
|
|
res.redirect('/');
|
|
return;
|
|
}
|
|
|
|
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
|
|
console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`);
|
|
|
|
res.redirect('/');
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'SSO callback failed');
|
|
console.error('[SSO] OIDC callback error:', msg);
|
|
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
|
}
|
|
});
|