const express = require("express"); const { S3Client, HeadObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { createRemoteJWKSet, jwtVerify } = require("jose"); const app = express(); app.use(express.json({ limit: "1mb" })); const PORT = Number(process.env.PORT || 8081); const OIDC_ISSUER = process.env.OIDC_ISSUER; const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE; const OIDC_JWKS_URL = process.env.OIDC_JWKS_URL; const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || OIDC_AUDIENCE; const RBAC_ROLE_PREFIX = process.env.RBAC_ROLE_PREFIX || "media_reader:folder:"; const RBAC_ROLE_ALL = process.env.RBAC_ROLE_ALL || "media_reader:all"; const S3_BUCKET = process.env.S3_BUCKET; const PRESIGN_TTL_SECONDS = Number(process.env.PRESIGN_TTL_SECONDS || 120); if (!OIDC_ISSUER || !OIDC_AUDIENCE || !OIDC_JWKS_URL || !S3_BUCKET) { // eslint-disable-next-line no-console console.error("Configuration manquante: OIDC_ISSUER/OIDC_AUDIENCE/OIDC_JWKS_URL/S3_BUCKET"); process.exit(1); } const jwks = createRemoteJWKSet(new URL(OIDC_JWKS_URL)); const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || "us-east-1", forcePathStyle: String(process.env.S3_FORCE_PATH_STYLE || "true") === "true", credentials: { accessKeyId: process.env.S3_ACCESS_KEY || "", secretAccessKey: process.env.S3_SECRET_KEY || "" } }); function extractBearerToken(req) { const h = req.headers.authorization || ""; if (!h.startsWith("Bearer ")) return null; return h.slice("Bearer ".length).trim(); } function getClientRoles(payload) { const byClient = payload?.resource_access?.[OIDC_CLIENT_ID]?.roles; if (Array.isArray(byClient)) return byClient; const byAudience = payload?.resource_access?.[OIDC_AUDIENCE]?.roles; if (Array.isArray(byAudience)) return byAudience; return []; } function computePermissions(roles) { const allowAll = roles.includes(RBAC_ROLE_ALL); const allowedPrefixes = allowAll ? [] : roles .filter((r) => r.startsWith(RBAC_ROLE_PREFIX)) .map((r) => `${r.slice(RBAC_ROLE_PREFIX.length).replace(/^\/+/, "")}/`); return { allowAll, allowedPrefixes }; } function isObjectAllowed(objectKey, permissions) { if (permissions.allowAll) return true; return permissions.allowedPrefixes.some((p) => objectKey.startsWith(p)); } async function requireAuth(req, res, next) { const token = extractBearerToken(req); if (!token) { return res.status(401).json({ message: "Bearer token requis" }); } try { const { payload } = await jwtVerify(token, jwks, { issuer: OIDC_ISSUER, audience: OIDC_AUDIENCE }); const roles = getClientRoles(payload); const permissions = computePermissions(roles); req.user = { sub: payload.sub || "unknown", roles, permissions }; return next(); } catch (_err) { return res.status(401).json({ message: "Token invalide" }); } } app.get("/health", (_req, res) => { res.json({ status: "ok", service: "media-access-api", audience: OIDC_AUDIENCE }); }); app.get("/v1/permissions", requireAuth, (req, res) => { res.json({ subject: req.user.sub, allowAll: req.user.permissions.allowAll, allowedPrefixes: req.user.permissions.allowedPrefixes }); }); app.post("/v1/presign", requireAuth, async (req, res) => { const objectKey = req.body?.objectKey; if (!objectKey || typeof objectKey !== "string") { return res.status(400).json({ message: "Champ objectKey requis" }); } if (!isObjectAllowed(objectKey, req.user.permissions)) { return res.status(403).json({ message: "Object non autorise" }); } try { await s3.send( new HeadObjectCommand({ Bucket: S3_BUCKET, Key: objectKey }) ); } catch (_err) { return res.status(404).json({ message: "Object introuvable" }); } try { const command = new GetObjectCommand({ Bucket: S3_BUCKET, Key: objectKey }); const url = await getSignedUrl(s3, command, { expiresIn: PRESIGN_TTL_SECONDS }); return res.json({ url, expiresIn: PRESIGN_TTL_SECONDS }); } catch (_err) { return res.status(500).json({ message: "Erreur generation URL signee" }); } }); app.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`media-access-api listening on ${PORT}`); });