Add OAuth integration for multiple providers and implement user linking
Some checks failed
Deploy to production / deploy (push) Failing after 49s

- Created OAuth credentials setup guide for Google, Microsoft, Keycloak, ORY, and ZITADEL.
- Added migration for oauth_identity table to store OAuth identities linked to users.
- Implemented OAuthIdentity model for managing OAuth identities in the database.
- Developed oauthService to handle OAuth login, user creation, and identity linking.
- Created OAuthCallbackView and OAuthUserCallbackView components for handling OAuth responses in the frontend.
- Added error handling and user feedback during the OAuth process.
This commit is contained in:
Torsten Schulz (local)
2026-05-15 13:59:40 +02:00
parent 464208e30e
commit ac57931928
16 changed files with 7620 additions and 949 deletions

View File

@@ -0,0 +1,578 @@
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 };
};