summaryrefslogtreecommitdiff
path: root/media-access-api
diff options
context:
space:
mode:
Diffstat (limited to 'media-access-api')
-rw-r--r--media-access-api/.dockerignore2
-rw-r--r--media-access-api/Dockerfile13
-rw-r--r--media-access-api/package.json19
-rw-r--r--media-access-api/server.js150
4 files changed, 184 insertions, 0 deletions
diff --git a/media-access-api/.dockerignore b/media-access-api/.dockerignore
new file mode 100644
index 0000000..3abe3d4
--- /dev/null
+++ b/media-access-api/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+npm-debug.log
diff --git a/media-access-api/Dockerfile b/media-access-api/Dockerfile
new file mode 100644
index 0000000..04d4f1e
--- /dev/null
+++ b/media-access-api/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --omit=dev
+
+COPY . .
+
+ENV PORT=8081
+EXPOSE 8081
+
+CMD ["npm", "start"]
diff --git a/media-access-api/package.json b/media-access-api/package.json
new file mode 100644
index 0000000..afcd2aa
--- /dev/null
+++ b/media-access-api/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "media-access-api",
+ "version": "0.1.0",
+ "private": true,
+ "description": "API ACL medias (JWT Keycloak + MinIO presign)",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.879.0",
+ "@aws-sdk/s3-request-presigner": "^3.879.0",
+ "express": "^4.21.2",
+ "jose": "^5.10.0"
+ }
+}
diff --git a/media-access-api/server.js b/media-access-api/server.js
new file mode 100644
index 0000000..bed038f
--- /dev/null
+++ b/media-access-api/server.js
@@ -0,0 +1,150 @@
+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}`);
+});