Files
TREK/server/src/routes/oidc.ts
T
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00

165 lines
6.1 KiB
TypeScript

import express, { Request, Response } from 'express';
import { setAuthCookie } from '../services/cookie';
import {
getOidcConfig,
discover,
createState,
consumeState,
createAuthCode,
consumeAuthCode,
exchangeCodeForToken,
getUserInfo,
verifyIdToken,
findOrCreateUser,
touchLastLogin,
generateToken,
frontendUrl,
getAppUrl,
} from '../services/oidcService';
import { resolveAuthToggles } from '../services/authService';
const router = express.Router();
// ---- GET /login ----------------------------------------------------------
router.get('/login', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.status(403).json({ error: 'SSO login is disabled.' });
}
const config = getOidcConfig();
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
}
try {
const doc = await discover(config.issuer, config.discoveryUrl);
const appUrl = getAppUrl();
if (!appUrl) {
return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
}
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
const inviteToken = req.query.invite as string | undefined;
const state = createState(redirectUri, inviteToken);
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectUri,
scope: process.env.OIDC_SCOPE || 'openid email profile',
state,
});
res.redirect(`${doc.authorization_endpoint}?${params}`);
} catch (err: unknown) {
console.error('[OIDC] Login error:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'OIDC login failed' });
}
});
// ---- GET /callback -------------------------------------------------------
router.get('/callback', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
}
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
if (oidcError) {
console.error('[OIDC] Provider error:', oidcError);
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
}
if (!code || !state) {
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
}
const pending = consumeState(state);
if (!pending) {
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
}
const config = getOidcConfig();
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
}
try {
const doc = await discover(config.issuer, config.discoveryUrl);
const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret);
if (!tokenData._ok || !tokenData.access_token) {
console.error('[OIDC] Token exchange failed: status', tokenData._status);
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
}
// Strict id_token verification: signature via JWKS + iss + aud.
// Previously only the access_token was used to hit userinfo, so a
// compromised provider or MITM could supply a crafted userinfo
// response the server would blindly trust. When the id_token is
// missing from the token response (non-compliant provider) we still
// reject — an Authorization Code flow MUST return one per OIDC Core.
if (!tokenData.id_token) {
console.error('[OIDC] Token response missing id_token — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=no_id_token'));
}
const idVerify = await verifyIdToken(
tokenData.id_token,
doc,
config.clientId,
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
console.error('[OIDC] id_token verification failed:', reason);
return res.redirect(frontendUrl('/login?oidc_error=id_token_invalid'));
}
const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
if (!userInfo.email) {
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
}
// Cross-check: the userinfo response must be for the same subject
// the id_token signed. Catches a compromised userinfo endpoint that
// speaks for a different principal than the id_token's claim.
const tokenSub = idVerify.claims.sub;
if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) {
console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login');
return res.redirect(frontendUrl('/login?oidc_error=subject_mismatch'));
}
const result = findOrCreateUser(userInfo, config, pending.inviteToken);
if ('error' in result) {
return res.redirect(frontendUrl('/login?oidc_error=' + result.error));
}
touchLastLogin(result.user.id);
const jwtToken = generateToken(result.user);
const authCode = createAuthCode(jwtToken);
res.redirect(frontendUrl('/login?oidc_code=' + authCode));
} catch (err: unknown) {
console.error('[OIDC] Callback error:', err);
res.redirect(frontendUrl('/login?oidc_error=server_error'));
}
});
// ---- GET /exchange -------------------------------------------------------
router.get('/exchange', (req: Request, res: Response) => {
const { code } = req.query as { code?: string };
if (!code) return res.status(400).json({ error: 'Code required' });
const result = consumeAuthCode(code);
if ('error' in result) return res.status(400).json({ error: result.error });
setAuthCookie(res, result.token, req);
res.json({ token: result.token });
});
export default router;