From d883be1b9c1bd8f9172a0353f8bcf85c4c83908d Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Mon, 25 May 2026 21:36:25 -0600 Subject: [PATCH] feat(jsn-bridge): volunteer-dashboard + SSO redirect bridges + minimal rebrand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new bridge endpoints powering JSN's Volunteer Hub UI: 1. POST /api/volunteer/bridge/dashboard — bridge-secret-gated. Looks up User by email, reuses volunteerDashboardService.getDashboard(userId) to compose the same VolunteerDashboardPayload shape /volunteer/dashboard returns internally. Identical shape so JSN frontend renders with no schema translation. 404 when no cmlite user (JSN should provision identity first). 2. POST /api/auth/bridge/issue-sso-token (server-to-server, bridge-secret- gated) + GET /api/auth/bridge/sso?token=…&redirectTo=… (browser-facing). Mints a 60s one-time-use JWT signed with new SSO_BRIDGE_SECRET; consume atomically deletes a Redis sentinel (sso:, TTL 60s) — returns 401 if already consumed. On success, generates cmlite's normal cml_refresh cookie via the existing authService.generateTokenPair, redirects to ${ADMIN_URL}${redirectTo}. The SPA hydrates session on mount from the refresh cookie, so the user lands at the target path already logged in. redirectTo sanitization mirrors JSN's sanitizeReturnTo — protocol- relative, backslash-trick, traversal, and control-char protections. Both endpoints rate-limited at rl:bridge-sso: prefix. Minimal rebrand for the SSO-entry experience (VolunteerLayout.tsx): - useSearchParams() reads ?layout=embedded → hides PublicNavBar so the new tab feels like a continuation of whatever opener context the user came from. Footer nav stays (it's the in-cmlite navigation surface). - ConfigProvider colorPrimary already reads from siteSettings.publicColor Primary. Set via SQL once: UPDATE site_settings SET "publicColorPrimary" = '#b8362b' — JSN's campaign red. Persistent. NEW env var SSO_BRIDGE_SECRET (z.string().min(32).optional()) lives only on cmlite — JSN never sees it. Generate with openssl rand -hex 32. Distinct from JSN_BRIDGE_SECRET and all JWT_* secrets. End-to-end verified: JSN /account → click "Open Volunteer Hub" → new tab opens at http://localhost:3095/volunteer?layout=embedded with user authenticated via SSO bridge redirect, chrome hidden by layout flag, primary color matched. Bunker Admin --- admin/src/components/VolunteerLayout.tsx | 10 +- api/src/config/env.ts | 7 ++ api/src/modules/auth/sso-bridge.routes.ts | 116 ++++++++++++++++++ api/src/modules/auth/sso-bridge.schemas.ts | 36 ++++++ api/src/modules/auth/sso-bridge.service.ts | 89 ++++++++++++++ .../volunteer/volunteer-bridge.routes.ts | 60 +++++++++ .../volunteer/volunteer-bridge.schemas.ts | 7 ++ .../volunteer/volunteer-bridge.service.ts | 29 +++++ api/src/server.ts | 4 + 9 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 api/src/modules/auth/sso-bridge.routes.ts create mode 100644 api/src/modules/auth/sso-bridge.schemas.ts create mode 100644 api/src/modules/auth/sso-bridge.service.ts create mode 100644 api/src/modules/volunteer/volunteer-bridge.routes.ts create mode 100644 api/src/modules/volunteer/volunteer-bridge.schemas.ts create mode 100644 api/src/modules/volunteer/volunteer-bridge.service.ts diff --git a/admin/src/components/VolunteerLayout.tsx b/admin/src/components/VolunteerLayout.tsx index b3d1a5c..d9b4d04 100644 --- a/admin/src/components/VolunteerLayout.tsx +++ b/admin/src/components/VolunteerLayout.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { useNavigate, useLocation, Outlet } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams, Outlet } from 'react-router-dom'; import { ConfigProvider, Layout, Typography, theme, Drawer, Divider, Alert, Tag } from 'antd'; import { LogoutOutlined, @@ -30,6 +30,12 @@ const { Content, Footer } = Layout; export default function VolunteerLayout() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); + // ?layout=embedded hides the top nav chrome (PublicNavBar) so the volunteer + // surface feels like a continuation of whatever embedded/opener context the + // user arrived from — currently the JSN /account → SSO redirect flow. The + // footer nav stays (it's the in-cmlite navigation surface). + const isEmbedded = searchParams.get('layout') === 'embedded'; const { user, logout } = useAuthStore(); const { settings } = useSettingsStore(); const [menuOpen, setMenuOpen] = useState(false); @@ -99,7 +105,7 @@ export default function VolunteerLayout() { }} > - + {!isEmbedded && } + redis.call(command, ...args) as Promise, + prefix: 'rl:bridge-sso:', + }), + message: { error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' } }, +}); + +// Used only on the issue endpoint (server-to-server). The consume endpoint is +// browser-facing and authorized by JWT possession instead. +function requireBridgeSecret(req: Request, res: Response, next: NextFunction) { + if (!env.JSN_BRIDGE_SECRET) { + return res.status(503).json({ error: 'BRIDGE_DISABLED' }); + } + const header = req.get('authorization') ?? ''; + const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`; + const headerBuf = Buffer.from(header); + const expectedBuf = Buffer.from(expected); + if ( + headerBuf.length !== expectedBuf.length || + !timingSafeEqual(headerBuf, expectedBuf) + ) { + return res.status(401).json({ error: 'UNAUTHORIZED' }); + } + next(); +} + +// JSN api calls this server-to-server. Returns a one-time URL. +router.post( + '/bridge/issue-sso-token', + bridgeRateLimit, + requireBridgeSecret, + validate(issueSsoTokenSchema), + async (req, res, next) => { + try { + const result = await ssoBridgeService.issueToken(req.body); + res.json(result); + } catch (err) { + next(err); + } + }, +); + +// Browser-facing. Verifies + deletes the one-time JWT, mints the normal cmlite +// refresh-token cookie, redirects to the cmlite SPA at the sanitized path. +// The SPA's auth store hydrates by calling /api/auth/refresh on mount, which +// reads the refresh cookie we just set. +router.get( + '/bridge/sso', + bridgeRateLimit, + validate(consumeSsoTokenSchema, 'query'), + async (req, res, next) => { + try { + const { token } = req.query as { token: string; redirectTo: string }; + const { user, redirectTo } = await ssoBridgeService.consumeToken(token); + + // Mint cmlite's normal refresh token + set the cookie. Device fingerprint + // ties the refresh token to this user-agent/IP combo, same as login. + const fingerprint = computeDeviceFingerprint(req); + const tokens = await authService.generateTokenPair( + { id: user.id, email: user.email, role: user.role as never }, + fingerprint, + ); + setRefreshCookie(req, res, tokens.refreshToken); + + logger.info('SSO bridge redirect', { userId: user.id, redirectTo }); + + // Redirect to the cmlite SPA. Cookie is set on /api/auth path (matches + // cmlite's normal login behavior), so /api/auth/refresh from the SPA on + // hydrate will see it. + const target = `${env.ADMIN_URL}${redirectTo}`; + res.redirect(302, target); + } catch (err) { + next(err); + } + }, +); + +export { router as ssoBridgeRouter }; diff --git a/api/src/modules/auth/sso-bridge.schemas.ts b/api/src/modules/auth/sso-bridge.schemas.ts new file mode 100644 index 0000000..f6a71cf --- /dev/null +++ b/api/src/modules/auth/sso-bridge.schemas.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +// JSN-side server-to-server call: ask cmlite to mint a one-time-use SSO token. +// The token is included in a URL JSN then opens in the user's browser via +// window.open(...) so the user lands on cmlite already logged in. +export const issueSsoTokenSchema = z.object({ + email: z.string().email(), + // Path within cmlite to land on after consuming the token. Sanitized below + // before being used as a redirect target. + redirectTo: z.string().max(512).default('/volunteer'), +}); + +export type IssueSsoTokenInput = z.infer; + +// Querystring on the consume endpoint (GET /api/auth/bridge/sso?token=...&redirectTo=...). +export const consumeSsoTokenSchema = z.object({ + token: z.string().min(20).max(4096), + redirectTo: z.string().max(512).default('/volunteer'), +}); + +export type ConsumeSsoTokenInput = z.infer; + +// Reject non-same-origin paths, protocol-relative tricks, traversal, control +// chars. Same defense pattern as JSN's sanitizeReturnTo in apps/api/src/routes/ +// auth.ts — copied here so cmlite has the same protection on its own redirect. +export function sanitizeRedirectTo(raw: string | undefined): string { + const FALLBACK = '/volunteer'; + if (typeof raw !== 'string' || raw.length === 0 || raw.length > 512) return FALLBACK; + if (!raw.startsWith('/')) return FALLBACK; + if (raw.startsWith('//')) return FALLBACK; // protocol-relative + if (raw.startsWith('/\\')) return FALLBACK; + if (raw.includes('..')) return FALLBACK; + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\s]/.test(raw)) return FALLBACK; + return raw; +} diff --git a/api/src/modules/auth/sso-bridge.service.ts b/api/src/modules/auth/sso-bridge.service.ts new file mode 100644 index 0000000..05ccd3d --- /dev/null +++ b/api/src/modules/auth/sso-bridge.service.ts @@ -0,0 +1,89 @@ +import jwt from 'jsonwebtoken'; +import { randomUUID } from 'node:crypto'; +import { prisma } from '../../config/database'; +import { redis } from '../../config/redis'; +import { env } from '../../config/env'; +import { AppError } from '../../middleware/error-handler'; +import { logger } from '../../utils/logger'; +import { authService } from './auth.service'; +import { sanitizeRedirectTo } from './sso-bridge.schemas'; +import type { IssueSsoTokenInput } from './sso-bridge.schemas'; + +const TOKEN_TTL_SECONDS = 60; +const TOKEN_ALGORITHM = 'HS256' as const; + +interface SsoTokenPayload { + sub: string; // cmlite user id + jti: string; // unique id used for the one-time-use Redis sentinel + redirectTo: string; +} + +export const ssoBridgeService = { + async issueToken(input: IssueSsoTokenInput): Promise<{ url: string }> { + if (!env.SSO_BRIDGE_SECRET) { + throw new AppError(503, 'SSO bridge not configured', 'SSO_BRIDGE_DISABLED'); + } + const email = input.email.trim().toLowerCase(); + const redirectTo = sanitizeRedirectTo(input.redirectTo); + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (!user) { + throw new AppError(404, 'No cmlite user for this email', 'NO_CMLITE_USER'); + } + + const jti = randomUUID(); + const payload: SsoTokenPayload = { sub: user.id, jti, redirectTo }; + const token = jwt.sign(payload, env.SSO_BRIDGE_SECRET, { + algorithm: TOKEN_ALGORITHM, + expiresIn: TOKEN_TTL_SECONDS, + }); + + // One-time-use sentinel. Setting with NX prevents accidental overwrite of + // an unrelated key (impossibly low collision risk on uuid, but cheap to + // be paranoid). EX matches the JWT expiry so Redis cleans up on its own. + await redis.set(`sso:${jti}`, '1', 'EX', TOKEN_TTL_SECONDS, 'NX'); + + const url = `${env.API_URL}/api/auth/bridge/sso?token=${encodeURIComponent(token)}&redirectTo=${encodeURIComponent(redirectTo)}`; + return { url }; + }, + + async consumeToken(rawToken: string): Promise<{ + user: { id: string; email: string; role: string }; + redirectTo: string; + }> { + if (!env.SSO_BRIDGE_SECRET) { + throw new AppError(503, 'SSO bridge not configured', 'SSO_BRIDGE_DISABLED'); + } + let payload: SsoTokenPayload; + try { + payload = jwt.verify(rawToken, env.SSO_BRIDGE_SECRET, { + algorithms: [TOKEN_ALGORITHM], + }) as SsoTokenPayload; + } catch (err) { + logger.debug('SSO token verify failed', { + err: err instanceof Error ? err.message : String(err), + }); + throw new AppError(401, 'Invalid or expired SSO token', 'SSO_TOKEN_INVALID'); + } + + // Atomically delete the sentinel — if it returns 0, the token was already + // consumed (or expired, or was never minted by us). Either way, reject. + const deleted = await redis.del(`sso:${payload.jti}`); + if (deleted === 0) { + throw new AppError(401, 'SSO token already used or expired', 'SSO_TOKEN_CONSUMED'); + } + + const user = await prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, email: true, role: true }, + }); + if (!user) { + throw new AppError(404, 'User no longer exists', 'NO_CMLITE_USER'); + } + + return { user, redirectTo: sanitizeRedirectTo(payload.redirectTo) }; + }, +}; diff --git a/api/src/modules/volunteer/volunteer-bridge.routes.ts b/api/src/modules/volunteer/volunteer-bridge.routes.ts new file mode 100644 index 0000000..8a6fce7 --- /dev/null +++ b/api/src/modules/volunteer/volunteer-bridge.routes.ts @@ -0,0 +1,60 @@ +import { Router, type Request, type Response, type NextFunction } from 'express'; +import { timingSafeEqual } from 'node:crypto'; +import rateLimit from 'express-rate-limit'; +import RedisStore from 'rate-limit-redis'; +import { redis } from '../../config/redis'; +import { env } from '../../config/env'; +import { validate } from '../../middleware/validate'; +import { bridgeVolunteerDashboardSchema } from './volunteer-bridge.schemas'; +import { volunteerBridgeService } from './volunteer-bridge.service'; + +const router = Router(); + +const bridgeRateLimit = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + store: new RedisStore({ + sendCommand: (command: string, ...args: string[]) => + redis.call(command, ...args) as Promise, + prefix: 'rl:bridge-volunteer:', + }), + message: { + error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' }, + }, +}); + +function requireBridgeSecret(req: Request, res: Response, next: NextFunction) { + if (!env.JSN_BRIDGE_SECRET) { + return res.status(503).json({ error: 'BRIDGE_DISABLED' }); + } + const header = req.get('authorization') ?? ''; + const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`; + const headerBuf = Buffer.from(header); + const expectedBuf = Buffer.from(expected); + if ( + headerBuf.length !== expectedBuf.length || + !timingSafeEqual(headerBuf, expectedBuf) + ) { + return res.status(401).json({ error: 'UNAUTHORIZED' }); + } + next(); +} + +router.post( + '/bridge/dashboard', + bridgeRateLimit, + requireBridgeSecret, + validate(bridgeVolunteerDashboardSchema), + async (req, res, next) => { + try { + const dash = await volunteerBridgeService.getDashboardByEmail(req.body); + res.json(dash); + } catch (err) { + next(err); + } + }, +); + +export { router as volunteerBridgeRouter }; diff --git a/api/src/modules/volunteer/volunteer-bridge.schemas.ts b/api/src/modules/volunteer/volunteer-bridge.schemas.ts new file mode 100644 index 0000000..03e3be8 --- /dev/null +++ b/api/src/modules/volunteer/volunteer-bridge.schemas.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const bridgeVolunteerDashboardSchema = z.object({ + email: z.string().email(), +}); + +export type BridgeVolunteerDashboardInput = z.infer; diff --git a/api/src/modules/volunteer/volunteer-bridge.service.ts b/api/src/modules/volunteer/volunteer-bridge.service.ts new file mode 100644 index 0000000..895bf58 --- /dev/null +++ b/api/src/modules/volunteer/volunteer-bridge.service.ts @@ -0,0 +1,29 @@ +import { prisma } from '../../config/database'; +import { AppError } from '../../middleware/error-handler'; +import { + volunteerDashboardService, + type VolunteerDashboardPayload, +} from '../volunteer-dashboard/volunteer-dashboard.service'; +import type { BridgeVolunteerDashboardInput } from './volunteer-bridge.schemas'; + +export const volunteerBridgeService = { + async getDashboardByEmail( + input: BridgeVolunteerDashboardInput, + ): Promise { + const email = input.email.trim().toLowerCase(); + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (!user) { + throw new AppError(404, 'No cmlite user for this email', 'NO_CMLITE_USER'); + } + const dash = await volunteerDashboardService.getDashboard(user.id); + if (!dash) { + // getDashboard returns null only if profile lookup fails — same effect + // as user-not-found for our purposes, but distinct code to aid debug. + throw new AppError(404, 'Dashboard could not be assembled', 'DASHBOARD_UNAVAILABLE'); + } + return dash; + }, +}; diff --git a/api/src/server.ts b/api/src/server.ts index bc21022..3a2f034 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -18,6 +18,8 @@ import { giteaSsoRouter } from './modules/auth/gitea-sso.routes'; import { identityBridgeRouter } from './modules/auth/identity-bridge.routes'; import { socialLinkBridgeRouter } from './modules/auth/social-link-bridge.routes'; import { donationBridgeRouter } from './modules/payments/donation-bridge.routes'; +import { volunteerBridgeRouter } from './modules/volunteer/volunteer-bridge.routes'; +import { ssoBridgeRouter } from './modules/auth/sso-bridge.routes'; import { usersRouter } from './modules/users/users.routes'; import { provisioningRouter } from './modules/users/provisioning.routes'; import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes'; @@ -275,6 +277,8 @@ app.use('/api/auth', authRouter); app.use('/api/auth', identityBridgeRouter); // JSN bridge: server-to-server user provisioning app.use('/api/auth', socialLinkBridgeRouter); // JSN bridge: social handle sync app.use('/api/payments', donationBridgeRouter); // JSN bridge: donation mirror +app.use('/api/volunteer', volunteerBridgeRouter); // JSN bridge: volunteer dashboard data +app.use('/api/auth', ssoBridgeRouter); // JSN bridge: SSO redirect (issue + consume) app.use('/api/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request) app.use('/api/users', usersRouter); app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)