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
|
# 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