diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be048a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM alpine:3.22 AS source +RUN apk add --no-cache unzip +WORKDIR /src +COPY annonet-arrowstack-ready.zip /tmp/annonet.zip +RUN unzip -q /tmp/annonet.zip -d /src +COPY mcpAuthMiddleware.js /src/src/auth/mcpAuthMiddleware.js + +FROM node:22-alpine AS frontend-build +WORKDIR /build/frontend-astro +COPY --from=source /src/frontend-astro/package*.json ./ +RUN npm ci +COPY --from=source /src/frontend-astro/ ./ +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8787 + +COPY --from=source /src/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=source /src/src ./src +COPY --from=source /src/sql ./sql +COPY --from=source /src/scripts ./scripts +COPY --from=source /src/frontend ./frontend +COPY --from=frontend-build /build/frontend-astro/dist ./public + +RUN npm run check \ + && mkdir -p /app/uploads/images \ + && cp /app/frontend/annotation-overlay.js /app/public/annotation-overlay.js \ + && cp /app/frontend/*.html /app/public/ + +EXPOSE 8787 + +HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \ + CMD node -e "fetch('http://127.0.0.1:8787/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "start"] diff --git a/README.md b/README.md index 16995e1..64aa6a9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ # annoNet -Arrowstack deployment repository. +Arrowstack production deployment for the uploaded `annonet-arrowstack-ready.zip` source of truth. + +The Docker image preserves the supplied production layout and applies one minimal +machine-auth override before building: + +- Keycloak token signature, issuer and expiry are verified by the supplied OIDC verifier. +- The token's actual authorized party/client identity must be `annonet-mcp`; audience alone is not enough. +- The required machine role remains `annonet-mcp`. +- annoNet org/team/agent authority comes only from `ANNONET_MCP_ORG_ID`, + `ANNONET_MCP_TEAM_IDS` and `ANNONET_MCP_AGENT_NAME`. +- `ANNONET_MCP_TOKEN` remains optional for local/bootstrap compatibility and is empty in production. + +Runtime port: `8787` + +Readiness: `/api/health/ready` diff --git a/mcpAuthMiddleware.js b/mcpAuthMiddleware.js new file mode 100644 index 0000000..acf0369 --- /dev/null +++ b/mcpAuthMiddleware.js @@ -0,0 +1,96 @@ +const crypto = require("crypto"); +const { verifyOidcAccessToken } = require("./oidcVerifier"); +const { + mcpServiceToken, + mcpOrgId, + mcpTeamIds, + mcpAgentName, + mcpOidcClientIds, + mcpRequiredRoles +} = require("../config"); + +const getBearerToken = (req) => { + const raw = String(req.get("authorization") || "").trim(); + if (!raw.toLowerCase().startsWith("bearer ")) return null; + return raw.slice(7).trim() || null; +}; + +const safeEqual = (left, right) => { + const a = Buffer.from(String(left || "")); + const b = Buffer.from(String(right || "")); + if (a.length === 0 || a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +}; + +const attachScope = (req, agentName, identity = {}) => { + req.auth = { + userId: `mcp:${agentName}`, + orgId: mcpOrgId, + role: "agent", + isProductAdmin: false, + email: null, + name: agentName, + defaultTeamId: mcpTeamIds[0] || null, + teams: mcpTeamIds.map((id) => ({ id })), + teamIds: [...mcpTeamIds], + actorType: "mcp", + ...identity + }; +}; + +const requireMcpAuth = async (req, res, next) => { + try { + const scopeConfigured = Boolean(mcpOrgId && mcpTeamIds.length > 0); + const authConfigured = Boolean(mcpServiceToken || mcpOidcClientIds.length > 0); + if (!scopeConfigured || !authConfigured) { + return res.status(503).json({ error: "MCP bridge is not configured" }); + } + + const token = getBearerToken(req); + if (!token) { + return res.status(401).json({ error: "MCP bearer token required" }); + } + + if (mcpServiceToken && safeEqual(token, mcpServiceToken)) { + attachScope(req, mcpAgentName, { identityProvider: "static-token" }); + return next(); + } + + if (mcpOidcClientIds.length === 0) { + return res.status(401).json({ error: "invalid MCP service token" }); + } + + let verified; + try { + verified = await verifyOidcAccessToken(token, { + allowedClientIds: mcpOidcClientIds, + requiredRoles: mcpRequiredRoles + }); + } catch (error) { + return res.status(error.status || 401).json({ error: error.message || "invalid Arrowstack MCP token" }); + } + + const claims = verified.claims || {}; + const clientId = String(verified.clientId || "").trim(); + if (!clientId || !mcpOidcClientIds.includes(clientId)) { + return res.status(401).json({ error: "access token authorized party is not an allowed MCP client" }); + } + + // Authentication proves the Keycloak machine identity. annoNet authorization + // remains explicitly constrained by ANNONET_MCP_ORG_ID, ANNONET_MCP_TEAM_IDS, + // and ANNONET_MCP_AGENT_NAME rather than token-controlled claims. + attachScope(req, mcpAgentName, { + identityProvider: "keycloak", + identitySubject: String(claims.sub || "").trim() || null, + identityClientId: clientId, + identityRoles: verified.roles + }); + return next(); + } catch (error) { + return next(error); + } +}; + +module.exports = { + requireMcpAuth +};