Skip to content
Merged
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
107 changes: 54 additions & 53 deletions infra/.env.dev.enc

Large diffs are not rendered by default.

109 changes: 55 additions & 54 deletions infra/.env.enc

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions infra/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ DATACITE_DEPOSIT_URL=https://deposit.com
SLACK_WEBHOOK_URL=https://slack.com
SENTRY_AUTH_TOKEN=xxx
SENTRY_ORG=xxx
SESSION_SECRET=shhhhhh
# AWS_ACCESS_KEY_ID: Required
# AWS_SECRET_ACCESS_KEY: Required
# AWS_BACKUP_ACCESS_KEY_ID: Required
Expand Down
3 changes: 3 additions & 0 deletions server/envSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ export const envSchema = z.object({
'JSON array of search terms for the "By Content" tab. Each element is a string or [name, ...aliases]',
),

// ── Session ─────────────────────────────────────────────────────────
SESSION_SECRET: z.string().min(1).describe('Secret for signing session cookies'),

Comment thread
tefkah marked this conversation as resolved.
// ── Testing ──────────────────────────────────────────────────────────
INTEGRATION_TESTING: booleanish.describe('Signals that integration tests are running'),
TEST_FASTLY_PURGE: booleanish.describe('Enable Fastly purge calls during tests'),
Expand Down
266 changes: 213 additions & 53 deletions server/kf/api.ts

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions server/kf/oidc.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export async function buildAuthorizeUrl(
state: string,
existingVerifier?: string,
context?: string,
prompt?: string,
): Promise<{ url: string; codeVerifier: string }> {
const config = await discover();
const codeVerifier = existingVerifier ?? generateCodeVerifier();
Expand All @@ -165,6 +166,7 @@ export async function buildAuthorizeUrl(
code_challenge: codeChallenge,
code_challenge_method: 'S256',
...(context && { context }),
...(prompt && { prompt }),
});

return { url: `${authorizeUrl.toString()}?${params}`, codeVerifier };
Expand All @@ -178,6 +180,28 @@ export interface TokenResponse {
refresh_token?: string;
}

/**
* Extract claims from the ID token without signature verification —
* the token came straight from the token endpoint over a trusted
* server-to-server channel, so its contents are already authentic.
* `sid` is the kf-auth session id (requires enableEndSession on the
* OAuth client); it lets us correlate local sessions with kf-auth
* sessions for the session.revoked webhook.
*/
export function decodeIdTokenClaims(idToken: string): { sub?: string; sid?: string } {
try {
const payloadPart = idToken.split('.')[1];
if (!payloadPart) return {};
const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
return {
sub: typeof payload.sub === 'string' ? payload.sub : undefined,
sid: typeof payload.sid === 'string' ? payload.sid : undefined,
};
} catch {
return {};
}
}

/**
* Exchange an authorization code for tokens (server-to-server).
*/
Expand Down Expand Up @@ -271,6 +295,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
125 changes: 125 additions & 0 deletions server/kf/webhookHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { User } from 'server/models';
import { upsertSpamTag } from 'server/spamTag/userQueries';
import { deleteSessionsByKfSessionId, 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 });
}

/**
* A single kf-auth session was revoked (user revoked a device from the
* Security page, signed out, etc.). Delete exactly the local sessions
* that were minted from it — they're stamped with the kf-auth session
* id (the ID token's `sid` claim) at login.
*/
export async function handleSessionRevoked(data: any, res: any) {
const { sessionId } = data;

if (!sessionId) {
return res.status(400).json({ error: 'sessionId is required' });
}

const deleted = await deleteSessionsByKfSessionId(sessionId);

return res.status(200).json({ ok: true, deleted });
}
43 changes: 43 additions & 0 deletions server/middleware/silentReauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { NextFunction, Request, Response } from 'express';

const SKIP_PREFIXES = ['/api', '/auth', '/dist', '/static', '/service-worker', '/favicon'];

/**
* Detects "was logged in, session expired" and triggers silent re-auth
* via OIDC prompt=none. Only fires for browser page loads (GET requests
* to non-API, non-asset paths).
*
* Uses the `pp-lic` CDN cookie (set at login, 30-day maxAge) to detect
* that the user was previously authenticated. A `pp-renew-failed` cookie
* acts as a circuit breaker to prevent redirect loops when kf-auth's
* session is also expired.
*/
export const silentReauthMiddleware = () => {
return (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') return next();
if (SKIP_PREFIXES.some((p) => req.path.startsWith(p))) {
return next();
}

if (req.user) return next();

// After logout it's set to 'pp-lo' - renewing would resurrect the session the user just deliberately ended.
const lic = req.cookies?.['pp-lic'];
if (typeof lic !== 'string' || !lic.startsWith('pp-li-')) return next();

// Circuit breaker: recently tried and failed - skip
if (req.cookies?.['pp-renew-failed']) return next();

// This 302 carries no Set-Cookie, so Fastly would otherwise cache it
// under the per-`pp-lic` cache key (vcl_hash only mixes connect.sid in
// for /api routes). A cached "go reauth" redirect would then be served
// even after the user has a valid session again — an infinite loop the
// session cookie can't bust. Mark it private/no-store so the edge
// passes it through (Fastly return(pass)es on `Cache-Control ~ private`).
res.set('Cache-Control', 'private, no-store');
res.set('Surrogate-Control', 'no-store');

const returnTo = req.originalUrl;
return res.redirect(`/auth/login?renew=true&return_to=${encodeURIComponent(returnTo)}`);
};
};
65 changes: 49 additions & 16 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const app = express();

const appRouter = Router();

import { getAppCommit, isProd, setAppCommit, setEnvironment } from 'utils/environment';
import { getAppCommit, isDuqDuq, isProd, setAppCommit, setEnvironment } from 'utils/environment';

// ACHTUNG: These calls must appear before we import any more of our own code to ensure that
// the environment, and in particular the choice of dev vs. prod, is configured correctly!
Expand All @@ -35,6 +35,7 @@ if (env.NODE_ENV !== 'test') {

import { communityBanGuard } from './middleware/communityBanGuard';
import { deduplicateSlash } from './middleware/deduplicateSlash';
import { silentReauthMiddleware } from './middleware/silentReauth';
import { blocklistMiddleware } from './utils/blocklist';

import './hooks';
Expand Down Expand Up @@ -70,6 +71,23 @@ import { server } from 'utils/api/server';
// set BLOCKLIST_IP_ADDRESSES to comma separated list of ips (or partial ips) to block
appRouter.use(blocklistMiddleware);

// Fastly terminates TLS at the edge and forwards plain HTTP to the origin,
// marking TLS requests with the `Fastly-SSL` header (see vcl_recv). Both
// express-session (`proxy: true`) and express-sslify (`trustProtoHeader`)
// decide "is this secure?" from `X-Forwarded-Proto` only — if that header
// never reaches us the `Secure` session cookie is silently dropped and the
// user is trapped in a silent re-auth loop. Normalize the proto signal here,
// before any of those run. This only touches the header used for the secure
// decision; req.hostname/req.protocol (and community routing) are untouched.
if (isProd() || isDuqDuq()) {
appRouter.use((req, _res, next) => {
if (req.headers['fastly-ssl'] && !req.headers['x-forwarded-proto']) {
req.headers['x-forwarded-proto'] = 'https';
}
next();
});
}

if (env.NODE_ENV === 'production') {
Sentry.init({
dsn: 'https://abe1c84bbb3045bd982f9fea7407efaa@sentry.io/1505439',
Expand Down Expand Up @@ -117,33 +135,47 @@ appRouter.use('/api/health', (req, res) => {

appRouter.use(
session({
secret: 'sessionsecret',
secret: env.SESSION_SECRET ?? 'sessionsecret',
resave: false,
saveUninitialized: false,
// TLS is terminated at the edge (Fastly) and forwarded as plain HTTP,
// so without trusting the proxy express-session sees an insecure
// connection and silently drops the `secure` cookie. This honors
// X-Forwarded-Proto for the secure-cookie decision ONLY — unlike a
// global `app.set('trust proxy')`, it leaves req.hostname untouched
// so community routing keeps working.
proxy: true,
store: env.NODE_ENV !== 'test' ? new SequelizeStore({ db: sequelize }) : undefined,
Comment thread
tefkah marked this conversation as resolved.
cookie: {
path: '/',
/* These are necessary for */
/* the api cookie to set */
/* ------- */
httpOnly: false,
secure: false,
/* ------- */
maxAge: 30 * 24 * 60 * 60 * 1000, // = 30 days.
httpOnly: true,
secure: env.NODE_ENV === 'production',
maxAge:
env.NODE_ENV === 'production'
? isDuqDuq()
? 1 * 60 * 1000
: 15 * 60 * 1000
: 10_000, // 1min duqduq, 15m prod, 10s dev for testing
Comment thread
tefkah marked this conversation as resolved.
},
}),
);

appRouter.use((req, res, next) => {
/* If on *.pubpub.org domain, set cookie to be accessible across */
/* all subdomains to maintain login. Especially important when */
/* If on a platform domain, set the session cookie to be accessible */
/* across all subdomains to maintain login. Especially important when */
/* creating communities. */
const hostname = req.headers.communityhostname || req.hostname;
if (hostname.indexOf('.pubpub.org') > -1) {
req.session.cookie.domain = '.pubpub.org';
}
if (hostname.indexOf('.duqduq.org') > -1) {
req.session.cookie.domain = '.duqduq.org';
const onPlatformDomain =
hostname.indexOf('.pubpub.org') > -1 || hostname.indexOf('.duqduq.org') > -1;
if (onPlatformDomain) {
/* Fastly maps *.duqduq.org → *.pubpub.org at the edge, so the */
/* hostname seen here can read ".pubpub.org" even on the duqduq */
/* deployment. Pick the parent domain from the deployment env (the */
/* same way the pp-lic cookie does) rather than the rewritten */
/* hostname — otherwise the session cookie gets pinned to */
/* .pubpub.org and is never sent back to *.duqduq.org, which traps */
/* the user in an infinite silent re-auth loop. */
req.session.cookie.domain = isDuqDuq() ? '.duqduq.org' : '.pubpub.org';
}
next();
});
Expand Down Expand Up @@ -254,6 +286,7 @@ appRouter.use(authTokenMiddleware);
appRouter.use(purgeMiddleware(schedulePurge));

appRouter.use(readOnlyMiddleware());
appRouter.use(silentReauthMiddleware());
appRouter.use(communityBanGuard());

const { customScript: _, ...contractWithoutCustomScript } = contract;
Expand Down
Loading
Loading