diff --git a/server/kf/api.ts b/server/kf/api.ts index cfd0a05d0b..05f5c19d7e 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -8,7 +8,7 @@ docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authoriz * POST /auth/logout — clear session + redirect to KF Auth logout * * Internal service-to-service endpoints (AUTH_INTERNAL_API_KEY): - * POST /api/kf/profile-sync — receive profile updates from KF Auth + * POST /api/kf/webhooks — receive webhook events from KF Auth (profile, bans, sessions) * GET /api/kf/branding — return community branding for login page * GET /api/kf/summary — return community list for a KF org * GET /api/kf/billing/usage — return usage stats for billing (placeholder) @@ -39,6 +39,12 @@ import { OIDC_ISSUER_URL, } from './auth'; import { provisionLocalUser } from './provisionLocalUser'; +import { + handleUserBanned, + handleUserSessionsRevoked, + handleUserUnbanned, + handleUserUpdated, +} from './webhookHandlers'; // ── Helpers ────────────────────────────────────────────────────────── @@ -250,44 +256,31 @@ router.post('/auth/logout', (req: any, res: any) => { }); }); -// ─── Profile sync (webhook from KF Auth) ───────────────────────────── - -router.post('/api/kf/profile-sync', requireInternalKey, async (req: any, res: any) => { - try { - const { userId, givenName, familyName, displayName, email, image } = req.body; - - if (!userId) { - return res.status(400).json({ error: 'userId is required' }); - } +// ─── Webhooks from KF Auth ────────────────────────────────────────── - const user = await User.findOne({ where: { id: userId } }); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } +router.post('/api/kf/webhooks', requireInternalKey, async (req: any, res: any) => { + const event = req.headers['x-kf-webhook-event']; + const { data } = req.body; - const updates: Record = {}; - if (displayName !== undefined) updates.fullName = displayName; - if (givenName !== undefined) updates.firstName = givenName; - if (familyName !== undefined) updates.lastName = familyName; - if (email !== undefined) updates.email = email.toLowerCase(); - if (image !== undefined) updates.avatar = image; - - // Recalculate initials when name changes - if (givenName !== undefined || familyName !== undefined || displayName !== undefined) { - const first = givenName ?? user.firstName ?? ''; - const last = familyName ?? user.lastName ?? ''; - if (first || last) { - updates.initials = `${first.charAt(0)}${last.charAt(0)}`.toUpperCase(); - } - } + if (!event || !data) { + return res.status(400).json({ error: 'Missing event header or data' }); + } - if (Object.keys(updates).length > 0) { - await user.update(updates); + try { + switch (event) { + case 'user.updated': + return await handleUserUpdated(data, res); + case 'user.banned': + return await handleUserBanned(data, res); + case 'user.unbanned': + return await handleUserUnbanned(data, res); + case 'user.sessions-revoked': + return await handleUserSessionsRevoked(data, res); + default: + return res.status(200).json({ ok: true, ignored: true }); } - - return res.status(200).json({ ok: true }); } catch (err) { - console.error('Profile sync error:', err); + console.error(`Webhook handler error [${event}]:`, err); return res.status(500).json({ error: 'Internal error' }); } }); diff --git a/server/kf/oidc.server.ts b/server/kf/oidc.server.ts index 0544a1e423..1ff3e4de96 100644 --- a/server/kf/oidc.server.ts +++ b/server/kf/oidc.server.ts @@ -271,6 +271,50 @@ export async function fetchUserOrgs(userId: string): Promise { return data.orgs ?? []; } +// --- Outbound ban sync --- + +export async function syncBanToKfAuth(userId: string, reason?: string): Promise { + if (!AUTH_INTERNAL_API_KEY) return; + + try { + const res = await fetch(`${AUTH_INTERNAL_API_URL}/api/internal/users/${userId}/ban`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_INTERNAL_API_KEY}`, + }, + body: JSON.stringify({ reason: reason ?? 'banned via PubPub spam system' }), + }); + if (!res.ok) { + const text = await res.text(); + console.error(`syncBanToKfAuth failed for ${userId}: HTTP ${res.status} ${text}`); + } + } catch (err) { + console.error(`syncBanToKfAuth failed for ${userId}:`, err); + } +} + +export async function syncUnbanToKfAuth(userId: string): Promise { + if (!AUTH_INTERNAL_API_KEY) return; + + try { + const res = await fetch(`${AUTH_INTERNAL_API_URL}/api/internal/users/${userId}/unban`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_INTERNAL_API_KEY}`, + }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const text = await res.text(); + console.error(`syncUnbanToKfAuth failed for ${userId}: HTTP ${res.status} ${text}`); + } + } catch (err) { + console.error(`syncUnbanToKfAuth failed for ${userId}:`, err); + } +} + // --- Exports --- export { diff --git a/server/kf/webhookHandlers.ts b/server/kf/webhookHandlers.ts new file mode 100644 index 0000000000..d415a0c384 --- /dev/null +++ b/server/kf/webhookHandlers.ts @@ -0,0 +1,107 @@ +import { User } from 'server/models'; +import { upsertSpamTag } from 'server/spamTag/userQueries'; +import { deleteSessionsForUser } from 'server/utils/session'; + +export async function handleUserUpdated(data: any, res: any) { + const { userId, givenName, familyName, displayName, email, image } = data; + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const user = await User.findOne({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const updates: Record = {}; + if (displayName !== undefined) updates.fullName = displayName; + if (givenName !== undefined) updates.firstName = givenName; + if (familyName !== undefined) updates.lastName = familyName; + if (email !== undefined) updates.email = email.toLowerCase(); + if (image !== undefined) updates.avatar = image; + + if (givenName !== undefined || familyName !== undefined || displayName !== undefined) { + const first = givenName ?? user.firstName ?? ''; + const last = familyName ?? user.lastName ?? ''; + if (first || last) { + updates.initials = `${first.charAt(0)}${last.charAt(0)}`.toUpperCase(); + } + } + + if (Object.keys(updates).length > 0) { + await user.update(updates); + } + + return res.status(200).json({ ok: true }); +} + +export async function handleUserBanned(data: any, res: any) { + const { userId, banReason } = data; + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const user = await User.findOne({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + await upsertSpamTag({ + userId, + status: 'confirmed-spam', + fields: { + manuallyMarkedBy: [ + { + userId: 'kf-auth', + userName: banReason ? `KF Auth: ${banReason}` : 'KF Auth (external ban)', + at: new Date().toISOString(), + }, + ], + }, + skipKfAuthSync: true, + }); + + return res.status(200).json({ ok: true }); +} + +export async function handleUserUnbanned(data: any, res: any) { + const { userId } = data; + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const user = await User.findOne({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + await upsertSpamTag({ + userId, + status: 'confirmed-not-spam', + skipKfAuthSync: true, + }); + + return res.status(200).json({ ok: true }); +} + +export async function handleUserSessionsRevoked(data: any, res: any) { + const { userId } = data; + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const user = await User.findOne({ where: { id: userId } }); + if (!user) { + return res.status(200).json({ ok: true, skipped: 'user not found' }); + } + + if (user.email) { + await deleteSessionsForUser(user.email); + } + + return res.status(200).json({ ok: true }); +} diff --git a/server/spamTag/userQueries.ts b/server/spamTag/userQueries.ts index 338d32e109..40578a35c2 100644 --- a/server/spamTag/userQueries.ts +++ b/server/spamTag/userQueries.ts @@ -14,6 +14,8 @@ import { SpamTag, User, } from 'server/models'; +import { syncBanToKfAuth, syncUnbanToKfAuth } from 'server/kf/oidc.server'; +import { defer } from 'server/utils/deferred'; import { deleteSessionsForUser } from 'server/utils/session'; import { expect } from 'utils/assert'; import { schedulePurge } from 'utils/caching/schedulePurgeWithSentry'; @@ -40,6 +42,7 @@ type UpsertSpamTagOptions = { userId: string; fields?: UserSpamTagFields; status?: types.SpamStatus; + skipKfAuthSync?: boolean; }; type UpsertResult = { spamTag: SpamTag; user: User }; @@ -82,12 +85,13 @@ const schedulePurgesForUser = async (userId: string) => { }; export const upsertSpamTag = async (options: UpsertSpamTagOptions): Promise => { - const { userId, fields, status } = options; + const { userId, fields, status, skipKfAuthSync } = options; const user = await fetchUserWithSpamTag(userId); const verdict = getSuspectedUserSpamVerdict(user); const existingTag = user.spamTag; if (existingTag) { + const previousStatus = existingTag.status; const data = buildSpamTagData( verdict, existingTag.fields as UserSpamTagFields, @@ -95,8 +99,14 @@ export const upsertSpamTag = async (options: UpsertSpamTagOptions): Promise); - if (status === 'confirmed-spam' && existingTag.status !== status) { + if (status === 'confirmed-spam' && previousStatus !== status) { await Promise.all([invalidateUserSessions(user), schedulePurgesForUser(userId)]); + if (!skipKfAuthSync) { + defer(async () => syncBanToKfAuth(userId)); + } + } + if (status === 'confirmed-not-spam' && previousStatus !== status && !skipKfAuthSync) { + defer(async () => syncUnbanToKfAuth(userId)); } return { spamTag: existingTag, user }; } @@ -109,6 +119,12 @@ export const upsertSpamTag = async (options: UpsertSpamTagOptions): Promise syncBanToKfAuth(userId)); + } + } + if (status === 'confirmed-not-spam' && !skipKfAuthSync) { + defer(async () => syncUnbanToKfAuth(userId)); } return { spamTag, user }; }; @@ -123,14 +139,18 @@ export const getSpamTagForUser = async (userId: string) => { return u.spamTag ?? null; }; -export const removeSpamTagFromUser = async (userId: string) => { +export const removeSpamTagFromUser = async (userId: string, skipKfAuthSync?: boolean) => { const spamTag = await getSpamTagForUser(userId); if (!spamTag) return; + const wasBanned = spamTag.status === 'confirmed-spam'; await User.update( { spamTagId: null }, { where: { id: userId }, limit: 1, individualHooks: false }, ); await spamTag.destroy(); + if (wasBanned && !skipKfAuthSync) { + defer(async () => syncUnbanToKfAuth(userId)); + } }; const communityInclude = [