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)