tududi/backend/modules/oidc/provisioningService.js
Chris b0321b02fa
fix: resolve OIDC authentication error with existing identities (#1021)
Fixed "Cannot read properties of undefined (reading 'id')" error that occurred
when existing OIDC users attempted to log in. The issue was a case mismatch in
the Sequelize association accessor.

Changes:
- Fixed case mismatch: identity.user -> identity.User (matches association alias)
- Removed invalid username field that doesn't exist in User model
- Fixed admin role assignment to use Role model instead of User model
- Improved transaction error handling to prevent double rollbacks
- Removed unused generateUsername function

Tests:
- Added comprehensive test suite with 12 tests covering all provisioning scenarios
- All tests passing including the previously failing login scenario
2026-04-14 00:11:32 +03:00

193 lines
5.4 KiB
JavaScript

const { User, OIDCIdentity } = require('../../models');
const providerConfig = require('./providerConfig');
const { sequelize } = require('../../models');
function shouldBeAdmin(config, email) {
if (!config.adminEmailDomains || config.adminEmailDomains.length === 0) {
return false;
}
const domain = email.split('@')[1];
return config.adminEmailDomains.includes(domain);
}
async function findOrCreateIdentity(providerSlug, claims) {
const identity = await OIDCIdentity.findOne({
where: {
provider_slug: providerSlug,
subject: claims.sub,
},
include: [{ model: User, as: 'User' }],
});
return identity;
}
async function provisionUser(providerSlug, claims, req) {
const config = providerConfig.getProvider(providerSlug);
if (!config) {
throw new Error(`Provider not found: ${providerSlug}`);
}
const transaction = await sequelize.transaction();
try {
let identity = await OIDCIdentity.findOne({
where: {
provider_slug: providerSlug,
subject: claims.sub,
},
include: [{ model: User, as: 'User' }],
transaction,
});
if (identity) {
await identity.update(
{
last_login_at: new Date(),
email: claims.email || identity.email,
name: claims.name || identity.name,
picture: claims.picture || identity.picture,
raw_claims: claims,
},
{ transaction }
);
await transaction.commit();
return { user: identity.User, isNewUser: false };
}
if (!config.autoProvision) {
await transaction.rollback();
throw new Error('Auto-provisioning is disabled for this provider');
}
if (!claims.email) {
await transaction.rollback();
throw new Error('Email claim is required for provisioning');
}
let user = await User.findOne({
where: { email: claims.email },
transaction,
});
let isNewUser = false;
if (!user) {
user = await User.create(
{
email: claims.email,
verified_email: true,
password_digest: null,
},
{ transaction }
);
isNewUser = true;
if (shouldBeAdmin(config, claims.email)) {
const { Role } = require('../../models');
await Role.update(
{ is_admin: true },
{ where: { user_id: user.id }, transaction }
);
}
}
identity = await OIDCIdentity.create(
{
user_id: user.id,
provider_slug: providerSlug,
subject: claims.sub,
email: claims.email,
name: claims.name,
given_name: claims.given_name,
family_name: claims.family_name,
picture: claims.picture,
raw_claims: claims,
first_login_at: new Date(),
last_login_at: new Date(),
},
{ transaction }
);
await transaction.commit();
return { user, isNewUser };
} catch (error) {
if (!transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
async function linkIdentityToUser(userId, providerSlug, claims) {
const config = providerConfig.getProvider(providerSlug);
if (!config) {
throw new Error(`Provider not found: ${providerSlug}`);
}
const transaction = await sequelize.transaction();
try {
const existingIdentity = await OIDCIdentity.findOne({
where: {
provider_slug: providerSlug,
subject: claims.sub,
},
transaction,
});
if (existingIdentity) {
if (existingIdentity.user_id === userId) {
await transaction.commit();
return existingIdentity;
}
await transaction.rollback();
throw new Error(
'This OIDC identity is already linked to another user'
);
}
const user = await User.findByPk(userId, { transaction });
if (!user) {
await transaction.rollback();
throw new Error('User not found');
}
const identity = await OIDCIdentity.create(
{
user_id: userId,
provider_slug: providerSlug,
subject: claims.sub,
email: claims.email,
name: claims.name,
given_name: claims.given_name,
family_name: claims.family_name,
picture: claims.picture,
raw_claims: claims,
first_login_at: new Date(),
last_login_at: new Date(),
},
{ transaction }
);
await transaction.commit();
return identity;
} catch (error) {
if (!transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
module.exports = {
provisionUser,
linkIdentityToUser,
findOrCreateIdentity,
shouldBeAdmin,
};