chapterhouse 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
package/dist/api/auth.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import jwksClient from "jwks-rsa";
|
|
3
|
+
function getIssuer(tenantId) {
|
|
4
|
+
return `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
|
5
|
+
}
|
|
6
|
+
function getJwksUri(tenantId) {
|
|
7
|
+
return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
|
8
|
+
}
|
|
9
|
+
function getBearerToken(req) {
|
|
10
|
+
const headerAuth = req.headers.authorization;
|
|
11
|
+
if (typeof headerAuth === "string" && headerAuth.startsWith("Bearer ")) {
|
|
12
|
+
const token = headerAuth.slice("Bearer ".length).trim();
|
|
13
|
+
return token.length > 0 ? token : null;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function hasRequiredRole(claims, requiredRole) {
|
|
18
|
+
if (!requiredRole) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return Array.isArray(claims.roles) && claims.roles.includes(requiredRole);
|
|
22
|
+
}
|
|
23
|
+
function buildAuthenticatedUser(claims, teamLeadId) {
|
|
24
|
+
const id = claims.oid || claims.sub;
|
|
25
|
+
const name = claims.name;
|
|
26
|
+
const email = claims.preferred_username || claims.email || claims.upn;
|
|
27
|
+
if (!id || !name || !email) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
name,
|
|
33
|
+
email,
|
|
34
|
+
role: teamLeadId && id === teamLeadId ? "team-lead" : "engineer",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function unauthorized(res, message) {
|
|
38
|
+
res.status(401).json({ error: message });
|
|
39
|
+
}
|
|
40
|
+
// Module-level cache of JWKS clients keyed by tenant ID.
|
|
41
|
+
// Creating a new client per request discards the in-process key cache; keeping
|
|
42
|
+
// one client per tenant lets jwks-rsa honour its cacheMaxAge and avoids an
|
|
43
|
+
// outbound HTTPS call on every token verification.
|
|
44
|
+
const jwksClientByTenant = new Map();
|
|
45
|
+
function getJwksClientForTenant(tenantId) {
|
|
46
|
+
const existing = jwksClientByTenant.get(tenantId);
|
|
47
|
+
if (existing)
|
|
48
|
+
return existing;
|
|
49
|
+
const client = jwksClient({
|
|
50
|
+
jwksUri: getJwksUri(tenantId),
|
|
51
|
+
cache: true,
|
|
52
|
+
cacheMaxEntries: 5,
|
|
53
|
+
cacheMaxAge: 10 * 60 * 1000,
|
|
54
|
+
});
|
|
55
|
+
jwksClientByTenant.set(tenantId, client);
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
/** Exposed for test isolation — clears the module-level JWKS client cache. */
|
|
59
|
+
export function _resetJwksClientCache() {
|
|
60
|
+
jwksClientByTenant.clear();
|
|
61
|
+
}
|
|
62
|
+
export async function verifyEntraBearerToken(token, config) {
|
|
63
|
+
if (!config.entraTenantId || !config.entraClientId) {
|
|
64
|
+
throw new Error("Entra auth is enabled but ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing");
|
|
65
|
+
}
|
|
66
|
+
const client = getJwksClientForTenant(config.entraTenantId);
|
|
67
|
+
const getKey = (header, callback) => {
|
|
68
|
+
if (!header.kid) {
|
|
69
|
+
callback(new Error("JWT is missing a key id"));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
client.getSigningKey(header.kid)
|
|
73
|
+
.then((key) => {
|
|
74
|
+
callback(null, key.getPublicKey());
|
|
75
|
+
})
|
|
76
|
+
.catch((err) => {
|
|
77
|
+
callback(err instanceof Error ? err : new Error(String(err)));
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
return await new Promise((resolve, reject) => {
|
|
81
|
+
jwt.verify(token, getKey, {
|
|
82
|
+
algorithms: ["RS256"],
|
|
83
|
+
audience: config.entraClientId,
|
|
84
|
+
issuer: getIssuer(config.entraTenantId),
|
|
85
|
+
}, (err, decoded) => {
|
|
86
|
+
if (err) {
|
|
87
|
+
reject(err);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!decoded || typeof decoded === "string") {
|
|
91
|
+
reject(new Error("JWT payload is missing"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolve(decoded);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export function getBootstrapAuthResponse(apiToken, config) {
|
|
99
|
+
if (config.standaloneMode) {
|
|
100
|
+
return { authMode: "standalone" };
|
|
101
|
+
}
|
|
102
|
+
if (config.entraAuthEnabled) {
|
|
103
|
+
return { authMode: "entra" };
|
|
104
|
+
}
|
|
105
|
+
return { authMode: "legacy", token: apiToken };
|
|
106
|
+
}
|
|
107
|
+
export function createAuthMiddleware(options) {
|
|
108
|
+
const verifyBearerToken = options.verifyBearerToken ?? verifyEntraBearerToken;
|
|
109
|
+
return async function authMiddleware(req, res, next) {
|
|
110
|
+
if (options.config.standaloneMode) {
|
|
111
|
+
next();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const protectedPath = req.path === "/stream" || req.path.startsWith("/api/");
|
|
115
|
+
if (!protectedPath) {
|
|
116
|
+
next();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (req.path === "/api/bootstrap" || req.path === "/api/config/public" || req.path === "/status") {
|
|
120
|
+
next();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!options.config.entraAuthEnabled) {
|
|
124
|
+
const token = getBearerToken(req);
|
|
125
|
+
if (!options.apiToken || token !== options.apiToken) {
|
|
126
|
+
unauthorized(res, "Unauthorized");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
next();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const token = getBearerToken(req);
|
|
133
|
+
if (!token) {
|
|
134
|
+
unauthorized(res, "Unauthorized: missing Bearer token");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const claims = await verifyBearerToken(token, options.config);
|
|
139
|
+
if (options.config.entraRequiredRole && !hasRequiredRole(claims, options.config.entraRequiredRole)) {
|
|
140
|
+
console.warn(`[auth] Token for ${claims.preferred_username || claims.oid} rejected: ` +
|
|
141
|
+
`required role "${options.config.entraRequiredRole}" not found in roles=${JSON.stringify(claims.roles ?? [])}`);
|
|
142
|
+
unauthorized(res, "Unauthorized: user does not have the required app role");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const user = buildAuthenticatedUser(claims, options.config.entraTeamLeadId);
|
|
146
|
+
if (!user) {
|
|
147
|
+
unauthorized(res, "Unauthorized: token is missing required user claims");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
req.user = user;
|
|
151
|
+
next();
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
console.error("[auth] JWT verification failed:", err);
|
|
155
|
+
unauthorized(res, "Unauthorized: invalid or expired token");
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
function createMockResponse() {
|
|
4
|
+
return {
|
|
5
|
+
statusCode: 200,
|
|
6
|
+
body: undefined,
|
|
7
|
+
status(code) {
|
|
8
|
+
this.statusCode = code;
|
|
9
|
+
return this;
|
|
10
|
+
},
|
|
11
|
+
json(payload) {
|
|
12
|
+
this.body = payload;
|
|
13
|
+
return this;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function loadAuthModule() {
|
|
18
|
+
try {
|
|
19
|
+
return await import("./auth.js");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
test("falls back to the legacy API token when Entra auth is disabled", async () => {
|
|
26
|
+
const auth = await loadAuthModule();
|
|
27
|
+
assert.ok(auth, "auth module should exist");
|
|
28
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
29
|
+
const middleware = auth.createAuthMiddleware({
|
|
30
|
+
apiToken: "legacy-token",
|
|
31
|
+
config: {
|
|
32
|
+
entraAuthEnabled: false,
|
|
33
|
+
standaloneMode: false,
|
|
34
|
+
entraClientId: "",
|
|
35
|
+
entraTenantId: "",
|
|
36
|
+
entraRequiredRole: "",
|
|
37
|
+
entraTeamLeadId: "",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const req = {
|
|
41
|
+
path: "/api/model",
|
|
42
|
+
headers: { authorization: "Bearer legacy-token" },
|
|
43
|
+
query: {},
|
|
44
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
45
|
+
};
|
|
46
|
+
const res = createMockResponse();
|
|
47
|
+
let nextCalled = false;
|
|
48
|
+
await middleware(req, res, () => {
|
|
49
|
+
nextCalled = true;
|
|
50
|
+
});
|
|
51
|
+
assert.equal(nextCalled, true);
|
|
52
|
+
assert.equal(res.statusCode, 200);
|
|
53
|
+
assert.equal(req.user, undefined);
|
|
54
|
+
});
|
|
55
|
+
test("allows requests when standalone mode is enabled", async () => {
|
|
56
|
+
const auth = await loadAuthModule();
|
|
57
|
+
assert.ok(auth, "auth module should exist");
|
|
58
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
59
|
+
const middleware = auth.createAuthMiddleware({
|
|
60
|
+
apiToken: null,
|
|
61
|
+
config: {
|
|
62
|
+
entraAuthEnabled: false,
|
|
63
|
+
standaloneMode: true,
|
|
64
|
+
entraClientId: "",
|
|
65
|
+
entraTenantId: "",
|
|
66
|
+
entraRequiredRole: "",
|
|
67
|
+
entraTeamLeadId: "",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const req = {
|
|
71
|
+
path: "/api/model",
|
|
72
|
+
headers: {},
|
|
73
|
+
query: {},
|
|
74
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
75
|
+
};
|
|
76
|
+
const res = createMockResponse();
|
|
77
|
+
let nextCalled = false;
|
|
78
|
+
await middleware(req, res, () => {
|
|
79
|
+
nextCalled = true;
|
|
80
|
+
});
|
|
81
|
+
assert.equal(nextCalled, true);
|
|
82
|
+
assert.equal(res.statusCode, 200);
|
|
83
|
+
});
|
|
84
|
+
test("authenticates /stream with the Authorization header in legacy mode", async () => {
|
|
85
|
+
const auth = await loadAuthModule();
|
|
86
|
+
assert.ok(auth, "auth module should exist");
|
|
87
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
88
|
+
const middleware = auth.createAuthMiddleware({
|
|
89
|
+
apiToken: "legacy-token",
|
|
90
|
+
config: {
|
|
91
|
+
entraAuthEnabled: false,
|
|
92
|
+
standaloneMode: false,
|
|
93
|
+
entraClientId: "",
|
|
94
|
+
entraTenantId: "",
|
|
95
|
+
entraRequiredRole: "",
|
|
96
|
+
entraTeamLeadId: "",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const req = {
|
|
100
|
+
path: "/stream",
|
|
101
|
+
headers: { authorization: "Bearer legacy-token" },
|
|
102
|
+
query: {},
|
|
103
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
104
|
+
};
|
|
105
|
+
const res = createMockResponse();
|
|
106
|
+
let nextCalled = false;
|
|
107
|
+
await middleware(req, res, () => {
|
|
108
|
+
nextCalled = true;
|
|
109
|
+
});
|
|
110
|
+
assert.equal(nextCalled, true);
|
|
111
|
+
assert.equal(res.statusCode, 200);
|
|
112
|
+
});
|
|
113
|
+
test("rejects legacy /stream query-string tokens", async () => {
|
|
114
|
+
const auth = await loadAuthModule();
|
|
115
|
+
assert.ok(auth, "auth module should exist");
|
|
116
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
117
|
+
const middleware = auth.createAuthMiddleware({
|
|
118
|
+
apiToken: "legacy-token",
|
|
119
|
+
config: {
|
|
120
|
+
entraAuthEnabled: false,
|
|
121
|
+
standaloneMode: false,
|
|
122
|
+
entraClientId: "",
|
|
123
|
+
entraTenantId: "",
|
|
124
|
+
entraRequiredRole: "",
|
|
125
|
+
entraTeamLeadId: "",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const req = {
|
|
129
|
+
path: "/stream",
|
|
130
|
+
headers: {},
|
|
131
|
+
query: { token: "legacy-token" },
|
|
132
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
133
|
+
};
|
|
134
|
+
const res = createMockResponse();
|
|
135
|
+
let nextCalled = false;
|
|
136
|
+
await middleware(req, res, () => {
|
|
137
|
+
nextCalled = true;
|
|
138
|
+
});
|
|
139
|
+
assert.equal(nextCalled, false);
|
|
140
|
+
assert.equal(res.statusCode, 401);
|
|
141
|
+
assert.deepEqual(res.body, { error: "Unauthorized" });
|
|
142
|
+
});
|
|
143
|
+
test("allows unauthenticated access to the public config endpoint", async () => {
|
|
144
|
+
const auth = await loadAuthModule();
|
|
145
|
+
assert.ok(auth, "auth module should exist");
|
|
146
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
147
|
+
const middleware = auth.createAuthMiddleware({
|
|
148
|
+
apiToken: "legacy-token",
|
|
149
|
+
config: {
|
|
150
|
+
entraAuthEnabled: true,
|
|
151
|
+
standaloneMode: false,
|
|
152
|
+
entraClientId: "client-id",
|
|
153
|
+
entraTenantId: "tenant-id",
|
|
154
|
+
entraRequiredRole: "team.member",
|
|
155
|
+
entraTeamLeadId: "lead-user-id",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const req = {
|
|
159
|
+
path: "/api/config/public",
|
|
160
|
+
headers: {},
|
|
161
|
+
query: {},
|
|
162
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
163
|
+
};
|
|
164
|
+
const res = createMockResponse();
|
|
165
|
+
let nextCalled = false;
|
|
166
|
+
await middleware(req, res, () => {
|
|
167
|
+
nextCalled = true;
|
|
168
|
+
});
|
|
169
|
+
assert.equal(nextCalled, true);
|
|
170
|
+
assert.equal(res.statusCode, 200);
|
|
171
|
+
});
|
|
172
|
+
test("attaches the Entra user when bearer-token validation succeeds", async () => {
|
|
173
|
+
const auth = await loadAuthModule();
|
|
174
|
+
assert.ok(auth, "auth module should exist");
|
|
175
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
176
|
+
const middleware = auth.createAuthMiddleware({
|
|
177
|
+
apiToken: "legacy-token",
|
|
178
|
+
config: {
|
|
179
|
+
entraAuthEnabled: true,
|
|
180
|
+
standaloneMode: false,
|
|
181
|
+
entraClientId: "client-id",
|
|
182
|
+
entraTenantId: "tenant-id",
|
|
183
|
+
entraRequiredRole: "team.member",
|
|
184
|
+
entraTeamLeadId: "lead-user-id",
|
|
185
|
+
},
|
|
186
|
+
verifyBearerToken: async (token) => {
|
|
187
|
+
assert.equal(token, "entra-token");
|
|
188
|
+
return {
|
|
189
|
+
sub: "lead-user-id",
|
|
190
|
+
oid: "lead-user-id",
|
|
191
|
+
name: "Brian Ketelsen",
|
|
192
|
+
preferred_username: "brian@example.com",
|
|
193
|
+
aud: "client-id",
|
|
194
|
+
iss: "https://login.microsoftonline.com/tenant-id/v2.0",
|
|
195
|
+
roles: ["team.member"],
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const req = {
|
|
200
|
+
path: "/api/message",
|
|
201
|
+
headers: { authorization: "Bearer entra-token" },
|
|
202
|
+
query: {},
|
|
203
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
204
|
+
};
|
|
205
|
+
const res = createMockResponse();
|
|
206
|
+
let nextCalled = false;
|
|
207
|
+
await middleware(req, res, () => {
|
|
208
|
+
nextCalled = true;
|
|
209
|
+
});
|
|
210
|
+
assert.equal(nextCalled, true);
|
|
211
|
+
assert.deepEqual(req.user, {
|
|
212
|
+
id: "lead-user-id",
|
|
213
|
+
name: "Brian Ketelsen",
|
|
214
|
+
email: "brian@example.com",
|
|
215
|
+
role: "team-lead",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
test("rejects Entra tokens that do not satisfy the configured group restriction", async () => {
|
|
219
|
+
const auth = await loadAuthModule();
|
|
220
|
+
assert.ok(auth, "auth module should exist");
|
|
221
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
222
|
+
const middleware = auth.createAuthMiddleware({
|
|
223
|
+
apiToken: "legacy-token",
|
|
224
|
+
config: {
|
|
225
|
+
entraAuthEnabled: true,
|
|
226
|
+
standaloneMode: false,
|
|
227
|
+
entraClientId: "client-id",
|
|
228
|
+
entraTenantId: "tenant-id",
|
|
229
|
+
entraRequiredRole: "team.member",
|
|
230
|
+
entraTeamLeadId: "",
|
|
231
|
+
},
|
|
232
|
+
verifyBearerToken: async () => ({
|
|
233
|
+
sub: "engineer-user-id",
|
|
234
|
+
oid: "engineer-user-id",
|
|
235
|
+
name: "Ada Lovelace",
|
|
236
|
+
preferred_username: "ada@example.com",
|
|
237
|
+
aud: "client-id",
|
|
238
|
+
iss: "https://login.microsoftonline.com/tenant-id/v2.0",
|
|
239
|
+
roles: ["other.role"],
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
const req = {
|
|
243
|
+
path: "/api/wiki/pages",
|
|
244
|
+
headers: { authorization: "Bearer entra-token" },
|
|
245
|
+
query: {},
|
|
246
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
247
|
+
};
|
|
248
|
+
const res = createMockResponse();
|
|
249
|
+
let nextCalled = false;
|
|
250
|
+
await middleware(req, res, () => {
|
|
251
|
+
nextCalled = true;
|
|
252
|
+
});
|
|
253
|
+
assert.equal(nextCalled, false);
|
|
254
|
+
assert.equal(res.statusCode, 401);
|
|
255
|
+
assert.deepEqual(res.body, { error: "Unauthorized: user does not have the required app role" });
|
|
256
|
+
});
|
|
257
|
+
test("rejects Entra requests that do not include a bearer token", async () => {
|
|
258
|
+
const auth = await loadAuthModule();
|
|
259
|
+
assert.ok(auth, "auth module should exist");
|
|
260
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
261
|
+
const middleware = auth.createAuthMiddleware({
|
|
262
|
+
apiToken: "legacy-token",
|
|
263
|
+
config: {
|
|
264
|
+
entraAuthEnabled: true,
|
|
265
|
+
standaloneMode: false,
|
|
266
|
+
entraClientId: "client-id",
|
|
267
|
+
entraTenantId: "tenant-id",
|
|
268
|
+
entraRequiredRole: "team.member",
|
|
269
|
+
entraTeamLeadId: "lead-user-id",
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const req = {
|
|
273
|
+
path: "/stream",
|
|
274
|
+
headers: {},
|
|
275
|
+
query: {},
|
|
276
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
277
|
+
};
|
|
278
|
+
const res = createMockResponse();
|
|
279
|
+
let nextCalled = false;
|
|
280
|
+
await middleware(req, res, () => {
|
|
281
|
+
nextCalled = true;
|
|
282
|
+
});
|
|
283
|
+
assert.equal(nextCalled, false);
|
|
284
|
+
assert.equal(res.statusCode, 401);
|
|
285
|
+
assert.deepEqual(res.body, { error: "Unauthorized: missing Bearer token" });
|
|
286
|
+
});
|
|
287
|
+
test("rejects Entra tokens that are missing required user claims", async () => {
|
|
288
|
+
const auth = await loadAuthModule();
|
|
289
|
+
assert.ok(auth, "auth module should exist");
|
|
290
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
291
|
+
const middleware = auth.createAuthMiddleware({
|
|
292
|
+
apiToken: "legacy-token",
|
|
293
|
+
config: {
|
|
294
|
+
entraAuthEnabled: true,
|
|
295
|
+
standaloneMode: false,
|
|
296
|
+
entraClientId: "client-id",
|
|
297
|
+
entraTenantId: "tenant-id",
|
|
298
|
+
entraRequiredRole: "",
|
|
299
|
+
entraTeamLeadId: "lead-user-id",
|
|
300
|
+
},
|
|
301
|
+
verifyBearerToken: async () => ({
|
|
302
|
+
sub: "engineer-user-id",
|
|
303
|
+
oid: "engineer-user-id",
|
|
304
|
+
roles: ["team.member"],
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
const req = {
|
|
308
|
+
path: "/api/message",
|
|
309
|
+
headers: { authorization: "Bearer entra-token" },
|
|
310
|
+
query: {},
|
|
311
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
312
|
+
};
|
|
313
|
+
const res = createMockResponse();
|
|
314
|
+
let nextCalled = false;
|
|
315
|
+
await middleware(req, res, () => {
|
|
316
|
+
nextCalled = true;
|
|
317
|
+
});
|
|
318
|
+
assert.equal(nextCalled, false);
|
|
319
|
+
assert.equal(res.statusCode, 401);
|
|
320
|
+
assert.deepEqual(res.body, { error: "Unauthorized: token is missing required user claims" });
|
|
321
|
+
});
|
|
322
|
+
test("returns a generic message when JWT verification fails", async () => {
|
|
323
|
+
const auth = await loadAuthModule();
|
|
324
|
+
assert.ok(auth, "auth module should exist");
|
|
325
|
+
assert.equal(typeof auth.createAuthMiddleware, "function", "createAuthMiddleware should be exported");
|
|
326
|
+
const originalConsoleError = console.error;
|
|
327
|
+
const logged = [];
|
|
328
|
+
console.error = (...args) => {
|
|
329
|
+
logged.push(args);
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
const middleware = auth.createAuthMiddleware({
|
|
333
|
+
apiToken: "legacy-token",
|
|
334
|
+
config: {
|
|
335
|
+
entraAuthEnabled: true,
|
|
336
|
+
standaloneMode: false,
|
|
337
|
+
entraClientId: "client-id",
|
|
338
|
+
entraTenantId: "tenant-id",
|
|
339
|
+
entraRequiredRole: "",
|
|
340
|
+
entraTeamLeadId: "",
|
|
341
|
+
},
|
|
342
|
+
verifyBearerToken: async () => {
|
|
343
|
+
throw new Error("jwt malformed");
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
const req = {
|
|
347
|
+
path: "/api/message",
|
|
348
|
+
headers: { authorization: "Bearer bad-token" },
|
|
349
|
+
query: {},
|
|
350
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
351
|
+
};
|
|
352
|
+
const res = createMockResponse();
|
|
353
|
+
let nextCalled = false;
|
|
354
|
+
await middleware(req, res, () => {
|
|
355
|
+
nextCalled = true;
|
|
356
|
+
});
|
|
357
|
+
assert.equal(nextCalled, false);
|
|
358
|
+
assert.equal(res.statusCode, 401);
|
|
359
|
+
assert.deepEqual(res.body, { error: "Unauthorized: invalid or expired token" });
|
|
360
|
+
assert.equal(logged.length, 1);
|
|
361
|
+
assert.equal(logged[0]?.[0], "[auth] JWT verification failed:");
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
console.error = originalConsoleError;
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Auth edge cases: JWKS cache + Entra error paths
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
test("missing Entra tenantId throws a clear descriptive error, not a crash", async () => {
|
|
371
|
+
const auth = await loadAuthModule();
|
|
372
|
+
assert.ok(auth, "auth module should exist");
|
|
373
|
+
await assert.rejects(() => auth.verifyEntraBearerToken("any.jwt.token", {
|
|
374
|
+
entraAuthEnabled: true,
|
|
375
|
+
standaloneMode: false,
|
|
376
|
+
entraTenantId: "",
|
|
377
|
+
entraClientId: "some-client-id",
|
|
378
|
+
entraRequiredRole: "",
|
|
379
|
+
entraTeamLeadId: "",
|
|
380
|
+
}), (err) => {
|
|
381
|
+
assert.ok(err instanceof Error, "should throw an Error instance");
|
|
382
|
+
assert.match(err.message, /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "error message must identify the missing config");
|
|
383
|
+
return true;
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
test("missing Entra clientId throws a clear descriptive error, not a crash", async () => {
|
|
387
|
+
const auth = await loadAuthModule();
|
|
388
|
+
assert.ok(auth, "auth module should exist");
|
|
389
|
+
await assert.rejects(() => auth.verifyEntraBearerToken("any.jwt.token", {
|
|
390
|
+
entraAuthEnabled: true,
|
|
391
|
+
standaloneMode: false,
|
|
392
|
+
entraTenantId: "some-tenant-id",
|
|
393
|
+
entraClientId: "",
|
|
394
|
+
entraRequiredRole: "",
|
|
395
|
+
entraTeamLeadId: "",
|
|
396
|
+
}), (err) => {
|
|
397
|
+
assert.ok(err instanceof Error, "should throw an Error instance");
|
|
398
|
+
assert.match(err.message, /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "error message must identify the missing config");
|
|
399
|
+
return true;
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
test("JWT missing kid header returns 401, not 500", async () => {
|
|
403
|
+
const auth = await loadAuthModule();
|
|
404
|
+
assert.ok(auth, "auth module should exist");
|
|
405
|
+
const middleware = auth.createAuthMiddleware({
|
|
406
|
+
apiToken: null,
|
|
407
|
+
config: {
|
|
408
|
+
entraAuthEnabled: true,
|
|
409
|
+
standaloneMode: false,
|
|
410
|
+
entraClientId: "client-id",
|
|
411
|
+
entraTenantId: "tenant-id",
|
|
412
|
+
entraRequiredRole: "",
|
|
413
|
+
entraTeamLeadId: "",
|
|
414
|
+
},
|
|
415
|
+
verifyBearerToken: async () => {
|
|
416
|
+
const err = new Error("JWT is missing a key id");
|
|
417
|
+
err.name = "JsonWebTokenError";
|
|
418
|
+
throw err;
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
const req = {
|
|
422
|
+
path: "/api/message",
|
|
423
|
+
headers: { authorization: "Bearer token.without.kid" },
|
|
424
|
+
query: {},
|
|
425
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
426
|
+
};
|
|
427
|
+
const res = {
|
|
428
|
+
statusCode: 200,
|
|
429
|
+
body: undefined,
|
|
430
|
+
status(code) { this.statusCode = code; return this; },
|
|
431
|
+
json(payload) { this.body = payload; return this; },
|
|
432
|
+
};
|
|
433
|
+
let nextCalled = false;
|
|
434
|
+
await middleware(req, res, () => { nextCalled = true; });
|
|
435
|
+
assert.equal(nextCalled, false, "next() must not be called when kid is missing");
|
|
436
|
+
assert.equal(res.statusCode, 401, "status must be 401, not 500");
|
|
437
|
+
assert.deepEqual(res.body, { error: "Unauthorized: invalid or expired token" }, "response must not expose internal error details");
|
|
438
|
+
});
|
|
439
|
+
test("JWKS client is reused across calls for the same tenant (module-level cache)", async () => {
|
|
440
|
+
const auth = await loadAuthModule();
|
|
441
|
+
assert.ok(auth, "auth module should exist");
|
|
442
|
+
assert.equal(typeof auth._resetJwksClientCache, "function", "_resetJwksClientCache must be exported for test isolation");
|
|
443
|
+
auth._resetJwksClientCache();
|
|
444
|
+
const cfg = {
|
|
445
|
+
entraAuthEnabled: true,
|
|
446
|
+
standaloneMode: false,
|
|
447
|
+
entraTenantId: "test-tenant-for-cache",
|
|
448
|
+
entraClientId: "test-client",
|
|
449
|
+
entraRequiredRole: "",
|
|
450
|
+
entraTeamLeadId: "",
|
|
451
|
+
};
|
|
452
|
+
// Both calls will fail on a malformed JWT but neither should hit the
|
|
453
|
+
// "missing config" guard — which proves the JWKS client was constructed
|
|
454
|
+
// and reused rather than rebuilt on every invocation.
|
|
455
|
+
const err1 = await auth.verifyEntraBearerToken("not.a.real.jwt", cfg).catch((e) => e);
|
|
456
|
+
const err2 = await auth.verifyEntraBearerToken("not.a.real.jwt", cfg).catch((e) => e);
|
|
457
|
+
assert.ok(err1 instanceof Error, "first call must reject");
|
|
458
|
+
assert.ok(err2 instanceof Error, "second call must reject");
|
|
459
|
+
assert.doesNotMatch(String(err1.message), /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "first error must come from JWT processing, not config guard");
|
|
460
|
+
assert.doesNotMatch(String(err2.message), /ENTRA_TENANT_ID or ENTRA_CLIENT_ID is missing/, "second error must come from JWT processing — client reused from module cache");
|
|
461
|
+
auth._resetJwksClientCache();
|
|
462
|
+
});
|
|
463
|
+
//# sourceMappingURL=auth.test.js.map
|