tududi/backend/modules/oidc/controller.js
Chris ca77222eae
fix: resolve OIDC session loss and migration failures (#1023)
* fix: resolve OIDC session loss and migration failures

This commit fixes three critical issues affecting OIDC/SSO authentication:

1. Session Not Saved Before Redirect
   - Added explicit req.session.save() callback in OIDC callback handler
   - Ensures session is persisted before redirecting to /today
   - Prevents 401 errors after successful SSO authentication

2. Migration Resilience
   - Added DROP TABLE IF EXISTS users_new in migration
   - Prevents "table already exists" errors from failed migrations
   - Created cleanup script for orphaned migration tables

3. Trust Proxy Documentation
   - Documented TUDUDI_TRUST_PROXY requirement for reverse proxy deployments
   - Added troubleshooting guide for session loss issues
   - Updated .env.example with OIDC configuration examples

Fixes session loss when deployed behind reverse proxies (nginx, Traefik, etc.)

Changes:
- backend/modules/oidc/controller.js: Add session.save() before redirect
- backend/migrations/20260420000004-make-password-optional.js: Add DROP TABLE IF EXISTS
- backend/scripts/cleanup-failed-migration.js: New cleanup utility
- backend/.env.example: Add OIDC and trust proxy examples
- docs/10-oidc-sso.md: Add trust proxy configuration and troubleshooting
- docs/feature-plans/00-oidc-sso.md: Document required environment variables

* fix: prettier formatting in cleanup script
2026-04-14 07:53:55 +03:00

209 lines
6 KiB
JavaScript

const oidcService = require('./service');
const provisioningService = require('./provisioningService');
const oidcIdentityService = require('./oidcIdentityService');
const providerConfig = require('./providerConfig');
const auditService = require('./auditService');
async function listProviders(req, res) {
try {
const providers = providerConfig.getAllProviders();
const publicProviders = providers.map((p) => ({
slug: p.slug,
name: p.name,
type: 'oidc',
}));
res.json({ providers: publicProviders });
} catch (error) {
console.error('Error listing OIDC providers:', error);
res.status(500).json({ error: 'Failed to list providers' });
}
}
async function initiateAuth(req, res) {
try {
const { slug } = req.params;
const { authUrl } = await oidcService.initiateAuthFlow(slug, false);
res.redirect(authUrl);
} catch (error) {
console.error('Error initiating OIDC auth:', error);
const message = error.message || 'Failed to initiate authentication';
res.redirect(`/login?error=${encodeURIComponent(message)}`);
}
}
async function handleCallback(req, res) {
try {
const { slug } = req.params;
const result = await oidcService.handleCallback(slug, req.query);
if (result.linkMode) {
if (!req.currentUser) {
return res.redirect(
'/login?error=' +
encodeURIComponent(
'Authentication required to link account'
)
);
}
await provisioningService.linkIdentityToUser(
req.currentUser.id,
slug,
result.claims
);
await auditService.logOidcLinked(req.currentUser.id, slug, req);
return res.redirect('/profile/security?success=linked');
}
const { user, isNewUser } = await provisioningService.provisionUser(
slug,
result.claims,
req
);
req.session.userId = user.id;
await auditService.logOidcProvision(user.id, slug, req, isNewUser);
await auditService.logLoginSuccess(
user.id,
auditService.AUTH_METHODS.OIDC,
req,
slug
);
req.session.save((err) => {
if (err) {
console.error('Error saving session after OIDC login:', err);
return res.redirect(
'/login?error=' +
encodeURIComponent('Failed to establish session')
);
}
res.redirect('/today');
});
} catch (error) {
console.error('Error handling OIDC callback:', error);
await auditService.logLoginFailed(
null,
auditService.AUTH_METHODS.OIDC,
req,
req.params.slug,
error.message
);
const message = error.message || 'Authentication failed';
res.redirect(`/login?error=${encodeURIComponent(message)}`);
}
}
async function initiateLink(req, res) {
try {
if (!req.currentUser) {
return res.status(401).json({ error: 'Authentication required' });
}
const { slug } = req.params;
const { authUrl } = await oidcService.initiateAuthFlow(slug, true);
res.json({ redirectUrl: authUrl });
} catch (error) {
console.error('Error initiating OIDC link:', error);
res.status(500).json({
error: error.message || 'Failed to initiate linking',
});
}
}
async function unlinkIdentity(req, res) {
try {
if (!req.currentUser) {
return res.status(401).json({ error: 'Authentication required' });
}
const { identityId } = req.params;
const canUnlink = await oidcIdentityService.canUnlink(
identityId,
req.currentUser.id
);
if (!canUnlink.canUnlink) {
return res.status(400).json({ error: canUnlink.reason });
}
const identity = await oidcIdentityService.getIdentityById(identityId);
await oidcIdentityService.unlinkIdentity(
identityId,
req.currentUser.id
);
await auditService.logOidcUnlinked(
req.currentUser.id,
identity.provider_slug,
req
);
res.json({ success: true });
} catch (error) {
console.error('Error unlinking OIDC identity:', error);
res.status(500).json({
error: error.message || 'Failed to unlink identity',
});
}
}
async function getUserIdentities(req, res) {
try {
if (!req.currentUser) {
return res.status(401).json({ error: 'Authentication required' });
}
const identities = await oidcIdentityService.getUserIdentities(
req.currentUser.id
);
const providersMap = {};
providerConfig.getAllProviders().forEach((p) => {
providersMap[p.slug] = p;
});
const enrichedIdentities = identities.map((identity) => ({
id: identity.id,
provider_slug: identity.provider_slug,
provider_name:
providersMap[identity.provider_slug]?.name ||
identity.provider_slug,
email: identity.email,
name: identity.name,
picture: identity.picture,
first_login_at: identity.first_login_at,
last_login_at: identity.last_login_at,
created_at: identity.created_at,
}));
res.json({ identities: enrichedIdentities });
} catch (error) {
console.error('Error fetching OIDC identities:', error);
res.status(500).json({ error: 'Failed to fetch identities' });
}
}
module.exports = {
listProviders,
initiateAuth,
handleCallback,
initiateLink,
unlinkIdentity,
getUserIdentities,
};