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:
parent
11f23c0072
commit
d883be1b9c
@ -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() {
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100dvh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
|
||||
<PublicNavBar />
|
||||
{!isEmbedded && <PublicNavBar />}
|
||||
|
||||
<Content
|
||||
style={{
|
||||
|
||||
@ -282,6 +282,13 @@ const envSchema = z.object({
|
||||
// accept calls. Generate with: openssl rand -hex 32
|
||||
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
|
||||
// identity bridge after RC user provisioning. Belt-and-braces alongside RC's
|
||||
// default-channel flag (which only fires at user creation time, leaving
|
||||
|
||||
116
api/src/modules/auth/sso-bridge.routes.ts
Normal file
116
api/src/modules/auth/sso-bridge.routes.ts
Normal 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 };
|
||||
36
api/src/modules/auth/sso-bridge.schemas.ts
Normal file
36
api/src/modules/auth/sso-bridge.schemas.ts
Normal 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;
|
||||
}
|
||||
89
api/src/modules/auth/sso-bridge.service.ts
Normal file
89
api/src/modules/auth/sso-bridge.service.ts
Normal 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) };
|
||||
},
|
||||
};
|
||||
60
api/src/modules/volunteer/volunteer-bridge.routes.ts
Normal file
60
api/src/modules/volunteer/volunteer-bridge.routes.ts
Normal 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 };
|
||||
7
api/src/modules/volunteer/volunteer-bridge.schemas.ts
Normal file
7
api/src/modules/volunteer/volunteer-bridge.schemas.ts
Normal 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>;
|
||||
29
api/src/modules/volunteer/volunteer-bridge.service.ts
Normal file
29
api/src/modules/volunteer/volunteer-bridge.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user