Harden annoNet MCP OAuth service-account auth

This commit is contained in:
Admin 2026-09-21 21:33:46 +00:00
parent 50ede3e98e
commit b6e6e9cc38
3 changed files with 152 additions and 1 deletions

41
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,3 +1,17 @@
# annoNet # 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`

96
mcpAuthMiddleware.js Normal file
View File

@ -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
};