Add scouthub-eventi-be

This commit is contained in:
Lorenzo Sanesi
2026-07-25 11:59:25 +02:00
parent 9ceb05cda2
commit 7e0544c5ab
36 changed files with 7776 additions and 0 deletions
@@ -0,0 +1,17 @@
import { HttpError } from '../errors';
import { AuthContext } from './auth.types';
// Lancia 403 se l'utente non appartiene alla branca indicata. Chi ha ruolo
// capo-gruppo (realm o sull'organizzazione attiva del token, gia' confluiti in
// user.roles da verifyToken) salta il controllo: ha accesso in scrittura a
// tutte le branche del proprio gruppo.
export function assertBrancaAccess(user: AuthContext, brancaId: string): void {
if (user.roles.includes('capo-gruppo')) {
return;
}
const hasAccess = user.branche.some((branca) => branca.toLowerCase() === brancaId.toLowerCase());
if (!hasAccess) {
throw new HttpError(403, `Accesso alla branca '${brancaId}' non consentito`);
}
}
+15
View File
@@ -0,0 +1,15 @@
export interface AuthContext {
userId: string;
email: string | null;
orgId: string | null;
roles: string[];
// Branche dell'utente, ricavate dal claim "groups" del token (path normalizzati,
// senza lo slash iniziale: es. "Lupetti", "Capi"). Vedi assertBrancaAccess.
branche: string[];
}
// Contesto delle chiamate machine-to-machine (client credentials), popolato da
// verifyServiceToken. Distinto da AuthContext: non rappresenta un utente reale.
export interface ServiceAuthContext {
clientId: string;
}
@@ -0,0 +1,13 @@
import { Request } from 'express';
export function extractBearerToken(req: Request): string | null {
const header = req.headers.authorization;
if (!header) {
return null;
}
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
@@ -0,0 +1,10 @@
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
// Client JWKS condiviso tra verifyToken e verifyServiceToken: stesso realm,
// stessa chiave pubblica di firma.
export const jwksClientInstance = jwksClient({
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
@@ -0,0 +1,13 @@
import { Request, Response, NextFunction } from 'express';
// Da usare dopo verifyToken su tutte le route utente (mai insieme a verifyServiceToken):
// richiede che il token porti un'organizzazione attiva. L'org_id da usare per filtrare
// le query e' sempre req.auth.orgId, mai un org_id letto da params/query/body.
export function requireOrgId(req: Request, res: Response, next: NextFunction): void {
if (!req.auth?.orgId) {
res.status(403).json({ message: 'Nessuna organizzazione attiva sul token' });
return;
}
next();
}
@@ -0,0 +1,52 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { jwksClientInstance } from './jwks-client';
import { extractBearerToken } from './extract-bearer-token';
import { env } from '../config/env';
import { ServiceAuthContext } from './auth.types';
interface ServiceTokenPayload extends JwtPayload {
// "azp" (authorized party) e' il client_id del client che ha ottenuto il token
// via client credentials grant: e' il modo standard per riconoscere quale
// servizio sta chiamando in una chiamata machine-to-machine.
azp?: string;
}
// Middleware da usare al posto di (non insieme a) verifyToken sulle route
// machine-to-machine, ad es. POST /eventi/:id/risorse chiamata da altri backend
// (scouthub-attivita-be, scouthub-magazzino-be, ...) con le proprie credenziali
// di servizio. Il client chiamante deve essere elencato in
// KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS.
export async function verifyServiceToken(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
res.status(401).json({ message: 'Token mancante' });
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await jwksClientInstance.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
const clientId = (payload as ServiceTokenPayload).azp;
if (!clientId || !env.keycloak.authorizedServiceClients.includes(clientId)) {
res.status(403).json({ message: 'Client di servizio non autorizzato' });
return;
}
const service: ServiceAuthContext = { clientId };
req.service = service;
next();
} catch {
res.status(401).json({ message: 'Token non valido' });
}
}
@@ -0,0 +1,67 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { jwksClientInstance } from './jwks-client';
import { extractBearerToken } from './extract-bearer-token';
import { AuthContext } from './auth.types';
interface KeycloakTokenPayload extends JwtPayload {
sub: string;
email?: string;
realm_access?: { roles?: string[] };
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
// Un token porta al piu' un'organizzazione attiva per volta (vedi anche
// scouthub-magazzino-be/src/auth/verify-token.middleware.ts).
organization?: Record<string, { id: string; roles?: string[] }>;
// scouthub-attivita-be non espone ad oggi un claim branca dedicato nel token
// (vedi src/middlewares/authenticate.ts di quel progetto): finche' non verra'
// introdotto, la branca/i gruppi dell'utente sono ricavati dal claim standard
// "groups" di Keycloak, con path del tipo "/Lupetti", "/Capi".
groups?: string[];
}
function normalizeGroupPath(path: string): string {
return path.replace(/^\//, '');
}
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
const realmRoles = payload.realm_access?.roles ?? [];
const [organization] = payload.organization ? Object.values(payload.organization) : [];
const orgRoles = organization?.roles ?? [];
const groups = payload.groups ?? [];
return {
userId: payload.sub,
email: payload.email ?? null,
orgId: organization?.id ?? null,
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
branche: groups.map(normalizeGroupPath),
};
}
export async function verifyToken(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
res.status(401).json({ message: 'Token mancante' });
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await jwksClientInstance.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
next();
} catch {
res.status(401).json({ message: 'Token non valido' });
}
}