@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
208 lines (179 loc) • 6.74 kB
text/typescript
/**
* SSO User Service
*
* Handles user creation, lookup, and profile normalization for SSO authentication.
*/
import log from '@logger';
import { User } from '../../models';
import { generateApiKey } from '@utils/hash';
import { env } from '../../envConfig';
import { ssoEvents } from './events';
import { accountLinkingService } from './account-linking.service';
import type { NormalizedProfile, SSOProviderType, SSOUserResult } from './types';
import { LogOpts } from '@types';
const logMeta: LogOpts = { scope: 'sso-user-service', msgType: 'SSO' };
class SSOUserService {
private static instance: SSOUserService;
private constructor() {}
static getInstance(): SSOUserService {
if (!SSOUserService.instance) {
SSOUserService.instance = new SSOUserService();
}
return SSOUserService.instance;
}
/**
* Normalize profile from various SSO providers into a common format
*/
normalizeProfile(rawProfile: any, provider: SSOProviderType): NormalizedProfile {
// Handle different profile formats
const email = rawProfile.emails?.[0]?.value
|| rawProfile.email
|| rawProfile.nameID; // SAML
const id = rawProfile.id
|| rawProfile.sub
|| rawProfile.nameID
|| rawProfile.uid;
const givenName = rawProfile.name?.givenName
|| rawProfile.given_name
|| rawProfile.firstName
|| (rawProfile.name && typeof rawProfile.name === 'string' ? rawProfile.name.split(' ')[0] : undefined)
|| 'SSO';
const familyName = rawProfile.name?.familyName
|| rawProfile.family_name
|| rawProfile.lastName
|| (rawProfile.name && typeof rawProfile.name === 'string' ? rawProfile.name.split(' ').slice(1).join(' ') : undefined)
|| 'User';
return {
id,
email,
emails: email ? [{ value: email }] : [],
name: {
givenName,
familyName,
},
displayName: rawProfile.displayName || `${givenName} ${familyName}`,
provider: provider,
_json: rawProfile,
};
}
/**
* Process SSO user - find existing or create new
*/
async processUser(
profile: NormalizedProfile,
provider: SSOProviderType,
): Promise<SSOUserResult> {
const email = profile.email;
const providerId = profile.id;
const firstName = profile.name?.givenName || 'SSO';
const lastName = profile.name?.familyName || 'User';
if (!email) {
throw new Error('No email found in SSO profile');
}
// 1. Find by providerId first
let user = await accountLinkingService.findByProvider(provider, providerId);
if (user) {
log.info(`SSO login successful for ${email} via ${provider}`, logMeta);
ssoEvents.emitLogin(String(user._id), email, provider);
return {
user,
isNewUser: false,
wasLinked: false,
};
}
// 2. Find by email
user = await accountLinkingService.findByEmail(email);
if (user) {
// Check if we can auto-link
const linkCheck = await accountLinkingService.canAutoLink(email, provider);
if (linkCheck.canLink) {
const linkResult = await accountLinkingService.autoLink(user, provider, providerId);
if (linkResult.success) {
ssoEvents.emitLogin(String(user._id), email, provider);
return {
user: linkResult.user,
isNewUser: false,
wasLinked: true,
};
}
}
// If can't auto-link, update provider info if same provider type
if (user.provider === provider) {
user.providerId = providerId;
await user.save();
}
log.info(`SSO login successful for ${email} via ${provider}`, logMeta);
ssoEvents.emitLogin(String(user._id), email, provider);
return {
user,
isNewUser: false,
wasLinked: false,
};
}
// 3. Create new user if auto-create is enabled
if (!env.SSO_AUTO_CREATE_USERS) {
throw new Error('User not found and auto-creation is disabled');
}
const defaultRole = env.SSO_DEFAULT_ROLE || 'user';
const newUser = new User({
username: email,
firstName,
lastName,
role: defaultRole,
provider,
providerId,
password: generateApiKey(), // Random password (user can't login with it)
apiKey: generateApiKey(),
});
await newUser.save();
const newUserId = String((newUser as any)._id);
log.info(`SSO login successful for ${email} via ${provider} (new user created with role: ${defaultRole})`, logMeta);
ssoEvents.emitUserCreated(newUserId, email, provider);
ssoEvents.emitLogin(newUserId, email, provider);
return {
user: newUser,
isNewUser: true,
wasLinked: false,
};
}
/**
* Process SSO user with passport callback signature
*
* This is the callback signature expected by passport strategies
*/
async processUserCallback(
profile: any,
provider: SSOProviderType,
done: (error: any, user?: any) => void,
): Promise<void> {
try {
const normalizedProfile = this.normalizeProfile(profile, provider);
const result = await this.processUser(normalizedProfile, provider);
done(null, result.user);
} catch (error) {
log.error('Error processing SSO user', { ...logMeta, error });
ssoEvents.emitError(error as Error, profile.email, provider);
done(error);
}
}
/**
* Get user by ID
*/
async getUserById(userId: string): Promise<any | null> {
return User.findById(userId);
}
/**
* Update user's last login timestamp
*/
async updateLastLogin(userId: string): Promise<void> {
try {
await User.findByIdAndUpdate(userId, {
lastLogin: new Date(),
});
} catch (error) {
log.warn('Failed to update last login', { ...logMeta, error, userId });
}
}
}
export const ssoUserService = SSOUserService.getInstance();
export { SSOUserService };