feat(jsn-bridge): volunteer-dashboard + SSO redirect bridges + minimal rebrand

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:<jti>, 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
This commit is contained in:
bunker-admin 2026-05-25 21:36:25 -06:00
parent 11f23c0072
commit d883be1b9c
9 changed files with 356 additions and 2 deletions

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'; 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 { ConfigProvider, Layout, Typography, theme, Drawer, Divider, Alert, Tag } from 'antd';
import { import {
LogoutOutlined, LogoutOutlined,
@ -30,6 +30,12 @@ const { Content, Footer } = Layout;
export default function VolunteerLayout() { export default function VolunteerLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 { user, logout } = useAuthStore();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -99,7 +105,7 @@ export default function VolunteerLayout() {
}} }}
> >
<Layout style={{ minHeight: '100dvh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}> <Layout style={{ minHeight: '100dvh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
<PublicNavBar /> {!isEmbedded && <PublicNavBar />}
<Content <Content
style={{ style={{

View File

@ -282,6 +282,13 @@ const envSchema = z.object({
// accept calls. Generate with: openssl rand -hex 32 // accept calls. Generate with: openssl rand -hex 32
JSN_BRIDGE_SECRET: z.string().min(32).optional(), JSN_BRIDGE_SECRET: z.string().min(32).optional(),
// SSO redirect bridge: signs the short-lived (60s) one-time-use JWT issued
// by /api/auth/bridge/issue-sso-token and verified by /api/auth/bridge/sso.
// Lives only on cmlite — JSN never sees this secret; it asks cmlite to
// mint a URL and opens that URL in the user's browser. Generate with:
// openssl rand -hex 32. Distinct from JSN_BRIDGE_SECRET and JWT_*.
SSO_BRIDGE_SECRET: z.string().min(32).optional(),
// RC channel that every JSN-bridged supporter is invited into by the // RC channel that every JSN-bridged supporter is invited into by the
// identity bridge after RC user provisioning. Belt-and-braces alongside RC's // identity bridge after RC user provisioning. Belt-and-braces alongside RC's
// default-channel flag (which only fires at user creation time, leaving // default-channel flag (which only fires at user creation time, leaving

View File

@ -0,0 +1,116 @@
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 { authService } from './auth.service';
import { computeDeviceFingerprint } from '../../utils/device-fingerprint';
import { issueSsoTokenSchema, consumeSsoTokenSchema } from './sso-bridge.schemas';
import { ssoBridgeService } from './sso-bridge.service';
import { logger } from '../../utils/logger';
const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000;
// Mirrors auth.routes.ts setRefreshCookie. Re-implemented here rather than
// imported because auth.routes.ts keeps it un-exported; copying preserves
// future independence of the SSO bridge if auth.routes.ts changes its cookie
// strategy.
function setRefreshCookie(req: Request, res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: req.secure,
sameSite: req.secure ? 'strict' : 'lax',
maxAge: REFRESH_COOKIE_MAX_AGE,
path: '/api/auth',
});
}
const bridgeRateLimit = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) =>
redis.call(command, ...args) as Promise<any>,
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 };

View File

@ -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<typeof issueSsoTokenSchema>;
// 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<typeof consumeSsoTokenSchema>;
// 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;
}

View File

@ -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) };
},
};

View File

@ -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<any>,
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 };

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const bridgeVolunteerDashboardSchema = z.object({
email: z.string().email(),
});
export type BridgeVolunteerDashboardInput = z.infer<typeof bridgeVolunteerDashboardSchema>;

View File

@ -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<VolunteerDashboardPayload> {
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;
},
};

View File

@ -18,6 +18,8 @@ import { giteaSsoRouter } from './modules/auth/gitea-sso.routes';
import { identityBridgeRouter } from './modules/auth/identity-bridge.routes'; import { identityBridgeRouter } from './modules/auth/identity-bridge.routes';
import { socialLinkBridgeRouter } from './modules/auth/social-link-bridge.routes'; import { socialLinkBridgeRouter } from './modules/auth/social-link-bridge.routes';
import { donationBridgeRouter } from './modules/payments/donation-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 { usersRouter } from './modules/users/users.routes';
import { provisioningRouter } from './modules/users/provisioning.routes'; import { provisioningRouter } from './modules/users/provisioning.routes';
import { campaignsRouter } from './modules/influence/campaigns/campaigns.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', identityBridgeRouter); // JSN bridge: server-to-server user provisioning
app.use('/api/auth', socialLinkBridgeRouter); // JSN bridge: social handle sync app.use('/api/auth', socialLinkBridgeRouter); // JSN bridge: social handle sync
app.use('/api/payments', donationBridgeRouter); // JSN bridge: donation mirror 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/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request)
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles) app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)