import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { Op } from 'sequelize'; import * as oidc from 'openid-client'; import User from '../models/community/user.js'; import UserParam from '../models/community/user_param.js'; import UserParamType from '../models/type/user_param.js'; import UserParamValue from '../models/type/user_param_value.js'; import OAuthIdentity from '../models/community/oauth_identity.js'; import Friendship from '../models/community/friendship.js'; import { sequelize } from '../utils/sequelize.js'; import { redisClient } from '../utils/redis.js'; import { setUserSession, deleteUserSession } from '../utils/redis.js'; import { notifyUser } from '../utils/socket.js'; import { encrypt } from '../utils/encryption.js'; const saltRounds = 10; const OAUTH_STATE_TTL_SECONDS = 15 * 60; const OAUTH_CALLBACK_PATH = '/auth/oauth/callback'; const STATIC_PROVIDER_DEFS = [ { slug: 'google', label: 'Google', issuer: 'https://accounts.google.com', scope: 'openid email profile', }, { slug: 'microsoft', label: 'Microsoft', issuer: 'https://login.microsoftonline.com/common/v2.0', scope: 'openid email profile offline_access', }, { slug: 'keycloak', label: 'Keycloak', issuer: process.env.OAUTH_KEYCLOAK_ISSUER || '', scope: 'openid email profile', }, { slug: 'ory', label: 'ORY', issuer: process.env.OAUTH_ORY_ISSUER || '', scope: 'openid email profile', }, { slug: 'zitadel', label: 'ZITADEL', issuer: process.env.OAUTH_ZITADEL_ISSUER || '', scope: 'openid email profile', }, ]; const providerCache = new Map(); const buildEncryptedEmailCandidates = (email) => { const encrypted = encrypt(email); return [ Buffer.from(encrypted, 'utf8'), Buffer.from(encrypted, 'hex') ]; }; const getFriends = async (userId) => { const friendships = await Friendship.findAll({ where: { [Op.or]: [ { user1Id: userId }, { user2Id: userId }, ], accepted: true, }, include: [ { model: User, as: 'friendSender', attributes: ['hashedId', 'username'], }, { model: User, as: 'friendReceiver', attributes: ['hashedId', 'username'], }, ], }); return friendships.map((friendship) => ( friendship.user1Id === userId ? friendship.friendReceiver : friendship.friendSender )); }; const getFrontendCallbackUrl = () => { const frontendUrl = process.env.FRONTEND_URL; if (!frontendUrl) { throw new Error('missingfrontendurl'); } return new URL(OAUTH_CALLBACK_PATH, `${frontendUrl.replace(/\/$/, '')}/`).toString(); }; const normalizeClaims = (claims = {}) => ({ subject: claims.sub || claims.subject || '', email: typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null, verified: claims.email_verified === true || claims.email_verified === 'true', name: typeof claims.name === 'string' ? claims.name.trim() : '', preferredUsername: typeof claims.preferred_username === 'string' ? claims.preferred_username.trim() : '', givenName: typeof claims.given_name === 'string' ? claims.given_name.trim() : '', familyName: typeof claims.family_name === 'string' ? claims.family_name.trim() : '', }); const sanitizeUsernamePart = (value) => value .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 32); const createUniqueUsername = async (claims, providerSlug) => { const candidates = [ claims.preferredUsername, claims.name, claims.email ? claims.email.split('@')[0] : '', `${providerSlug}-${claims.subject.slice(0, 8)}`, ] .map(sanitizeUsernamePart) .filter(Boolean); const base = candidates[0] || `${providerSlug}-${claims.subject.slice(0, 8)}`; let attempt = 0; while (attempt < 50) { const suffix = attempt === 0 ? '' : String(attempt + 1); const username = `${base}${suffix}`.slice(0, 64); const existing = await User.findOne({ where: { username } }); if (!existing) { return username; } attempt += 1; } return `${providerSlug}-${claims.subject.slice(0, 12)}`.slice(0, 64); }; const buildFallbackEmail = (providerSlug, subject, email) => { if (email) { return email; } const safeSubject = subject.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 32) || crypto.randomBytes(8).toString('hex'); return `${providerSlug}-${safeSubject}@oauth.local`; }; const getProviderDefinitions = () => STATIC_PROVIDER_DEFS.map((provider) => ({ ...provider, configured: Boolean(provider.issuer) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_ID`]) && Boolean(process.env[`OAUTH_${provider.slug.toUpperCase()}_CLIENT_SECRET`]), })); const getProviderDefinition = (providerSlug) => getProviderDefinitions().find((provider) => provider.slug === providerSlug) || null; const getProviderConfiguration = async (providerSlug) => { if (providerCache.has(providerSlug)) { return providerCache.get(providerSlug); } const provider = getProviderDefinition(providerSlug); if (!provider || !provider.configured) { throw new Error('providernotconfigured'); } const clientId = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_ID`]; const clientSecret = process.env[`OAUTH_${providerSlug.toUpperCase()}_CLIENT_SECRET`]; const configuration = await oidc.discovery(new URL(provider.issuer), clientId, clientSecret); providerCache.set(providerSlug, configuration); return configuration; }; const storeOAuthState = async (state, payload) => { await redisClient.sendCommand([ 'SET', `oauth-state:${state}`, JSON.stringify(payload), 'EX', String(OAUTH_STATE_TTL_SECONDS) ]); }; const loadOAuthState = async (state) => { const raw = await redisClient.sendCommand(['GET', `oauth-state:${state}`]); if (!raw) { return null; } await redisClient.sendCommand(['DEL', `oauth-state:${state}`]); return JSON.parse(raw); }; const findUserByEmail = async (email) => { const encryptedEmailCandidates = buildEncryptedEmailCandidates(email); const query = ` SELECT id FROM community."user" WHERE email = ANY(:encryptedEmails) `; const rows = await sequelize.query(query, { replacements: { encryptedEmails: encryptedEmailCandidates }, type: sequelize.QueryTypes.SELECT, }); if (!rows.length) { return null; } return User.findByPk(rows[0].id); }; const loadUserParams = async (userId) => { const params = await UserParam.findAll({ where: { userId }, include: { model: UserParamType, as: 'paramType', where: { description: { [Op.in]: ['birthdate', 'gender', 'language'] } } } }); const mappedParams = params.map((param) => ({ name: param.paramType.description, value: param.value })); const languageEntry = mappedParams.find((param) => param.name === 'language'); if (languageEntry?.value) { const uiLocaleCodes = ['de', 'en', 'ceb', 'es', 'fr']; if (!uiLocaleCodes.includes(languageEntry.value)) { const numericValue = Number.parseInt(languageEntry.value, 10); const lookupId = Number.isNaN(numericValue) ? languageEntry.value : numericValue; const mappedValue = await UserParamValue.findOne({ where: { id: lookupId } }); if (mappedValue?.value && uiLocaleCodes.includes(mappedValue.value)) { languageEntry.value = mappedValue.value; } } } return mappedParams; }; const buildLoginPayload = async (user) => ({ id: user.hashedId, username: user.username, active: user.active, param: await loadUserParams(user.id), authCode: user.authCode, }); const setOAuthSession = async (user) => { const authCode = crypto.randomBytes(20).toString('hex'); user.authCode = authCode; await user.save(); const friends = await getFriends(user.id); for (const friend of friends) { await notifyUser(friend.hashedId, 'friendloginchanged', { userId: user.hashedId, status: 'online', }); } await setUserSession(user.id, { id: user.hashedId, username: user.username, active: user.active, authCode, timestamp: Date.now() }); return buildLoginPayload(user); }; const createUserFromClaims = async (providerSlug, providerConfiguration, claims) => { const email = buildFallbackEmail(providerSlug, claims.subject, claims.email); const username = await createUniqueUsername({ ...claims, email }, providerSlug); const hashedPassword = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), saltRounds); const user = await User.create({ email, username, password: hashedPassword, active: true, searchable: true, registrationDate: new Date(), }); await OAuthIdentity.create({ userId: user.id, provider: providerSlug, issuer: providerConfiguration.serverMetadata().issuer, subject: claims.subject, email, }); await user.reload(); return user; }; const linkIdentityToUser = async (user, providerSlug, providerConfiguration, claims) => { const existing = await OAuthIdentity.findOne({ where: { provider: providerSlug, subject: claims.subject, } }); if (existing) { if (existing.userId !== user.id) { throw new Error('oauthidentityconflict'); } return existing; } return OAuthIdentity.create({ userId: user.id, provider: providerSlug, issuer: providerConfiguration.serverMetadata().issuer, subject: claims.subject, email: claims.email || user.email, }); }; export const getOAuthProviders = async () => getProviderDefinitions().filter((provider) => provider.configured); export const startOAuthLogin = async ({ providerSlug }) => { const provider = getProviderDefinition(providerSlug); if (!provider || !provider.configured) { throw new Error('providernotconfigured'); } const configuration = await getProviderConfiguration(providerSlug); const codeVerifier = oidc.randomPKCECodeVerifier(); const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); const state = oidc.randomState(); const redirectUri = getFrontendCallbackUrl(); await storeOAuthState(state, { providerSlug, codeVerifier, redirectUri, createdAt: Date.now(), }); return oidc.buildAuthorizationUrl(configuration, { redirect_uri: redirectUri, scope: provider.scope, code_challenge: codeChallenge, code_challenge_method: 'S256', state, prompt: 'select_account', }); }; export const exchangeOAuthLogin = async ({ code, state }) => { if (!code || !state) { throw new Error('oauthcodemissing'); } const stateData = await loadOAuthState(state); if (!stateData?.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) { throw new Error('oauthstatemissing'); } const provider = getProviderDefinition(stateData.providerSlug); if (!provider || !provider.configured) { throw new Error('providernotconfigured'); } const configuration = await getProviderConfiguration(stateData.providerSlug); const callbackUrl = new URL(stateData.redirectUri); callbackUrl.searchParams.set('code', code); callbackUrl.searchParams.set('state', state); const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, { pkceCodeVerifier: stateData.codeVerifier, expectedState: state, }); let claims = typeof tokens.claims === 'function' ? tokens.claims() : null; if (!claims && tokens.access_token) { claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck); } const normalizedClaims = normalizeClaims(claims || {}); if (!normalizedClaims.subject) { throw new Error('oauthsubjectmissing'); } const identity = await OAuthIdentity.findOne({ where: { provider: provider.slug, subject: normalizedClaims.subject, } }); let user = identity ? await User.findByPk(identity.userId) : null; if (!user && normalizedClaims.email) { user = await findUserByEmail(normalizedClaims.email); } if (!user) { user = await createUserFromClaims(provider.slug, configuration, normalizedClaims); } else { await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims); if (!user.active) { user.active = true; await user.save(); } } if (!user.active) { user.active = true; await user.save(); } return setOAuthSession(user); }; export const logoutOAuthUser = async (hashedUserId) => { const user = await User.findOne({ where: { hashedId: hashedUserId } }); if (!user) { return; } const friends = await getFriends(user.id); for (const friend of friends) { await notifyUser(friend.hashedId, 'friendloginchanged', { userId: user.hashedId, status: 'online', }); } await deleteUserSession(user.id); }; export const getUserOAuthIdentities = async (userId) => { const identities = await OAuthIdentity.findAll({ where: { userId }, order: [['createdAt', 'DESC']] }); return identities.map(identity => ({ id: identity.id, provider: identity.provider, email: identity.email, createdAt: identity.createdAt, displayName: `${identity.provider} (${identity.email || identity.subject.slice(0, 10)})` })); }; export const startOAuthLoginForUser = async ({ userId, providerSlug }) => { const provider = getProviderDefinition(providerSlug); if (!provider || !provider.configured) { throw new Error('providernotconfigured'); } const configuration = await getProviderConfiguration(providerSlug); const codeVerifier = oidc.randomPKCECodeVerifier(); const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); const state = oidc.randomState(); const callbackPath = '/auth/oauth/user/callback'; const frontendUrl = process.env.FRONTEND_URL; if (!frontendUrl) { throw new Error('missingfrontendurl'); } const redirectUri = new URL(callbackPath, `${frontendUrl.replace(/\/$/, '')}/`).toString(); await storeOAuthState(state, { userId, providerSlug, codeVerifier, redirectUri, createdAt: Date.now(), }); return oidc.buildAuthorizationUrl(configuration, { redirect_uri: redirectUri, scope: provider.scope, code_challenge: codeChallenge, code_challenge_method: 'S256', state, prompt: 'select_account', }); }; export const exchangeOAuthLoginForUser = async ({ userId, code, state }) => { if (!code || !state) { throw new Error('oauthcodemissing'); } const stateData = await loadOAuthState(state); if (!stateData?.userId || !stateData.providerSlug || !stateData.codeVerifier || !stateData.redirectUri) { throw new Error('oauthstatemissing'); } if (stateData.userId !== userId) { throw new Error('oauthuseridmismatch'); } const provider = getProviderDefinition(stateData.providerSlug); if (!provider || !provider.configured) { throw new Error('providernotconfigured'); } const configuration = await getProviderConfiguration(stateData.providerSlug); const callbackUrl = new URL(stateData.redirectUri); callbackUrl.searchParams.set('code', code); callbackUrl.searchParams.set('state', state); const tokens = await oidc.authorizationCodeGrant(configuration, callbackUrl, { pkceCodeVerifier: stateData.codeVerifier, expectedState: state, }); let claims = typeof tokens.claims === 'function' ? tokens.claims() : null; if (!claims && tokens.access_token) { claims = await oidc.fetchUserInfo(configuration, tokens.access_token, oidc.skipSubjectCheck); } const normalizedClaims = normalizeClaims(claims || {}); if (!normalizedClaims.subject) { throw new Error('oauthsubjectmissing'); } const user = await User.findByPk(userId); if (!user) { throw new Error('usernotiound'); } const existingIdentity = await OAuthIdentity.findOne({ where: { provider: provider.slug, subject: normalizedClaims.subject, } }); if (existingIdentity && existingIdentity.userId !== userId) { throw new Error('oauthidentityalreadylinked'); } await linkIdentityToUser(user, provider.slug, configuration, normalizedClaims); return { success: true, identity: { id: existingIdentity?.id, provider: provider.slug, email: normalizedClaims.email, displayName: `${provider.label} (${normalizedClaims.email || normalizedClaims.subject.slice(0, 10)})` } }; }; export const removeOAuthIdentity = async ({ userId, identityId }) => { const identity = await OAuthIdentity.findByPk(identityId); if (!identity) { throw new Error('identitynotfound'); } if (identity.userId !== userId) { throw new Error('forbidden'); } await identity.destroy(); return { success: true }; };