Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 27 additions & 34 deletions server/kf/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -39,6 +39,12 @@ import {
OIDC_ISSUER_URL,
} from './auth';
import { provisionLocalUser } from './provisionLocalUser';
import {
handleUserBanned,
handleUserSessionsRevoked,
handleUserUnbanned,
handleUserUpdated,
} from './webhookHandlers';

// ── Helpers ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<string, any> = {};
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' });
}
});
Expand Down
44 changes: 44 additions & 0 deletions server/kf/oidc.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,50 @@ export async function fetchUserOrgs(userId: string): Promise<OIDCOrg[]> {
return data.orgs ?? [];
}

// --- Outbound ban sync ---

export async function syncBanToKfAuth(userId: string, reason?: string): Promise<void> {
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<void> {
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 {
Expand Down
107 changes: 107 additions & 0 deletions server/kf/webhookHandlers.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};
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 });
}
26 changes: 23 additions & 3 deletions server/spamTag/userQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -40,6 +42,7 @@ type UpsertSpamTagOptions = {
userId: string;
fields?: UserSpamTagFields;
status?: types.SpamStatus;
skipKfAuthSync?: boolean;
};

type UpsertResult = { spamTag: SpamTag; user: User };
Expand Down Expand Up @@ -82,21 +85,28 @@ const schedulePurgesForUser = async (userId: string) => {
};

export const upsertSpamTag = async (options: UpsertSpamTagOptions): Promise<UpsertResult> => {
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,
fields,
status,
);
await existingTag.update(data as types.SpamVerdict<SpamTag>);
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 };
}
Expand All @@ -109,6 +119,12 @@ export const upsertSpamTag = async (options: UpsertSpamTagOptions): Promise<Upse
);
if (status === 'confirmed-spam') {
await Promise.all([invalidateUserSessions(user), schedulePurgesForUser(userId)]);
if (!skipKfAuthSync) {
defer(async () => syncBanToKfAuth(userId));
}
}
if (status === 'confirmed-not-spam' && !skipKfAuthSync) {
defer(async () => syncUnbanToKfAuth(userId));
}
return { spamTag, user };
};
Expand All @@ -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 = [
Expand Down
Loading