Harden annoNet MCP OAuth service-account auth
This commit is contained in:
parent
50ede3e98e
commit
b6e6e9cc38
41
Dockerfile
Normal file
41
Dockerfile
Normal 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"]
|
||||
16
README.md
16
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`
|
||||
|
||||
96
mcpAuthMiddleware.js
Normal file
96
mcpAuthMiddleware.js
Normal 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
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user