@zereight/mcp-gitlab 2.1.70 → 2.2.0

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.
Files changed (157) hide show
  1. package/README.ko.md +3 -1
  2. package/README.md +6 -2
  3. package/README.pt-BR.md +1 -1
  4. package/README.zh-CN.md +3 -1
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +369 -184
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/package.json +3 -3
  20. package/build/scripts/check-skill-sync.js +0 -143
  21. package/build/scripts/generate-server-card.js +0 -39
  22. package/build/scripts/generate-tool-coverage.js +0 -58
  23. package/build/scripts/generate-tool-docs.js +0 -430
  24. package/build/scripts/tool-coverage/coverage.js +0 -341
  25. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  26. package/build/test/callback-proxy-tests.js +0 -321
  27. package/build/test/cli/args.test.js +0 -175
  28. package/build/test/cli/boolean-flags.test.js +0 -106
  29. package/build/test/cli/compact-mcp-result.test.js +0 -93
  30. package/build/test/cli/credentials.test.js +0 -153
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  50. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  51. package/build/test/gitlab-diff-schema.test.js +0 -36
  52. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  53. package/build/test/gitlab-tag-schema.test.js +0 -51
  54. package/build/test/group-milestone-schema.test.js +0 -45
  55. package/build/test/mcp-oauth-tests.js +0 -905
  56. package/build/test/mcp-server-name.test.js +0 -87
  57. package/build/test/merge-merge-request-schema.test.js +0 -75
  58. package/build/test/multi-server-test.js +0 -190
  59. package/build/test/no-proxy-integration-test.js +0 -183
  60. package/build/test/no-proxy-test.js +0 -149
  61. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  62. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  63. package/build/test/oauth-device-flow-tests.js +0 -450
  64. package/build/test/oauth-download-url.test.js +0 -157
  65. package/build/test/oauth-startup.test.js +0 -204
  66. package/build/test/oauth-tests.js +0 -428
  67. package/build/test/path-segment-encoding.test.js +0 -98
  68. package/build/test/remote-auth-simple-test.js +0 -277
  69. package/build/test/remote-auth-tests.js +0 -314
  70. package/build/test/response-masking.test.js +0 -445
  71. package/build/test/schema-tests.js +0 -1679
  72. package/build/test/server/metrics.test.js +0 -47
  73. package/build/test/server/request-helpers.test.js +0 -48
  74. package/build/test/server/version.test.js +0 -13
  75. package/build/test/sse-auth-guard.test.js +0 -96
  76. package/build/test/sse-session-limits.test.js +0 -273
  77. package/build/test/stateless/callback-proxy.test.js +0 -510
  78. package/build/test/stateless/client-id.test.js +0 -177
  79. package/build/test/stateless/codec.test.js +0 -328
  80. package/build/test/stateless/config-ttl.test.js +0 -149
  81. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  82. package/build/test/stateless/session-id-integration.test.js +0 -675
  83. package/build/test/stateless/session-id.test.js +0 -157
  84. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  85. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  86. package/build/test/streamable-http-sse-stream.test.js +0 -350
  87. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  88. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  89. package/build/test/test-all-transport-server.js +0 -255
  90. package/build/test/test-auth-retry.js +0 -195
  91. package/build/test/test-blank-filters.js +0 -156
  92. package/build/test/test-ci-catalog.js +0 -177
  93. package/build/test/test-ci-lint.js +0 -191
  94. package/build/test/test-ci-variables.js +0 -309
  95. package/build/test/test-create-repository.js +0 -120
  96. package/build/test/test-dependency-proxy.js +0 -195
  97. package/build/test/test-deployment-tools.js +0 -463
  98. package/build/test/test-download-attachment.js +0 -190
  99. package/build/test/test-dynamic-project-scope.js +0 -524
  100. package/build/test/test-empty-toolsets.js +0 -535
  101. package/build/test/test-get-file-blame.js +0 -145
  102. package/build/test/test-get-file-contents.js +0 -128
  103. package/build/test/test-geteffectiveprojectid.js +0 -804
  104. package/build/test/test-health-check.js +0 -115
  105. package/build/test/test-issue-description-patch.js +0 -256
  106. package/build/test/test-job-artifacts.js +0 -275
  107. package/build/test/test-json-schema.js +0 -166
  108. package/build/test/test-list-group-members.js +0 -150
  109. package/build/test/test-list-group-merge-requests.js +0 -165
  110. package/build/test/test-list-issues.js +0 -170
  111. package/build/test/test-list-merge-requests.js +0 -201
  112. package/build/test/test-list-milestones.js +0 -145
  113. package/build/test/test-list-project-members.js +0 -132
  114. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  115. package/build/test/test-merge-request-approvals.js +0 -187
  116. package/build/test/test-merge-request-pipelines.js +0 -106
  117. package/build/test/test-mr-diffs-filter.js +0 -132
  118. package/build/test/test-mr-file-diffs.js +0 -251
  119. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  120. package/build/test/test-orbit.js +0 -155
  121. package/build/test/test-permission-mode.js +0 -424
  122. package/build/test/test-pipeline-schedules.js +0 -484
  123. package/build/test/test-protected-branches.js +0 -174
  124. package/build/test/test-remote-downloads.js +0 -510
  125. package/build/test/test-search-code.js +0 -275
  126. package/build/test/test-snippets.js +0 -735
  127. package/build/test/test-tags.js +0 -220
  128. package/build/test/test-todos.js +0 -195
  129. package/build/test/test-token-optimizations.js +0 -691
  130. package/build/test/test-toolset-filtering.js +0 -499
  131. package/build/test/test-update-issue-slim.js +0 -141
  132. package/build/test/test-update-project.js +0 -112
  133. package/build/test/test-upload-markdown.js +0 -206
  134. package/build/test/test-vulnerabilities.js +0 -399
  135. package/build/test/test-webhooks.js +0 -218
  136. package/build/test/tool-description-quality.test.js +0 -50
  137. package/build/test/utils/bulk-publish-options.test.js +0 -32
  138. package/build/test/utils/download-token.test.js +0 -35
  139. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  140. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  141. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  142. package/build/test/utils/graphql-query.test.js +0 -168
  143. package/build/test/utils/helpers-path.test.js +0 -104
  144. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  145. package/build/test/utils/masking.test.js +0 -384
  146. package/build/test/utils/merge-request-position.test.js +0 -57
  147. package/build/test/utils/mock-gitlab-server.js +0 -614
  148. package/build/test/utils/proxy-client-ip.test.js +0 -28
  149. package/build/test/utils/redact-sensitive.test.js +0 -32
  150. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  151. package/build/test/utils/server-launcher.js +0 -231
  152. package/build/test/utils/tool-args.test.js +0 -210
  153. package/build/test/utils/url.test.js +0 -141
  154. package/build/test/utils/version-check.test.js +0 -239
  155. package/build/test/utils/wiki-title.test.js +0 -21
  156. package/build/test-note.js +0 -54
  157. package/build/test-resolve-issue-note.js +0 -127
@@ -1,177 +0,0 @@
1
- /**
2
- * Integration tests for the stateless `client_id` DCR path.
3
- *
4
- * The key cross-pod claim is that two GitLabOAuthServerProvider instances,
5
- * sharing only OAUTH_STATELESS_SECRET, can successfully issue a client_id on
6
- * one and validate it on the other — matching the scenario where a Kubernetes
7
- * load balancer routes POST /register to pod A and GET /authorize to pod B.
8
- */
9
- import assert from "node:assert/strict";
10
- import { randomBytes } from "node:crypto";
11
- import { describe, test } from "node:test";
12
- import { createGitLabOAuthProvider } from "../../oauth-proxy.js";
13
- import { mintClientId, openClientId, looksLikeStatelessClientId, } from "../../stateless/client-id.js";
14
- import { loadKeyMaterialFromEnv } from "../../stateless/index.js";
15
- // ---------------------------------------------------------------------------
16
- // Helpers
17
- // ---------------------------------------------------------------------------
18
- function secret() {
19
- return randomBytes(32).toString("base64url");
20
- }
21
- function loadMaterial(current, previous) {
22
- const env = {
23
- OAUTH_STATELESS_SECRET: current,
24
- };
25
- if (previous)
26
- env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
27
- const m = loadKeyMaterialFromEnv(true, env);
28
- assert.ok(m, "expected material to load");
29
- return m;
30
- }
31
- function makeProvider(material, { callbackProxy = false } = {}) {
32
- return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
33
- undefined, // customScopes
34
- undefined, // allowedGroups
35
- callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
36
- ? {
37
- material,
38
- clientTtlSeconds: 86400,
39
- pendingTtlSeconds: 600,
40
- storedTtlSeconds: 600,
41
- }
42
- : null);
43
- }
44
- // ---------------------------------------------------------------------------
45
- // mint / open helpers (direct)
46
- // ---------------------------------------------------------------------------
47
- describe("mintClientId / openClientId", () => {
48
- test("roundtrips redirect_uris", () => {
49
- const m = loadMaterial(secret());
50
- const cid = mintClientId(m, {
51
- redirectUris: ["https://client.example.com/cb"],
52
- grantTypes: ["authorization_code"],
53
- clientName: "Test Client",
54
- });
55
- assert.ok(looksLikeStatelessClientId(cid));
56
- const p = openClientId(m, cid, 86400);
57
- assert.ok(p);
58
- assert.deepEqual(p.ruris, ["https://client.example.com/cb"]);
59
- assert.deepEqual(p.gt, ["authorization_code"]);
60
- assert.equal(p.cn, "Test Client");
61
- });
62
- test("openClientId returns null on signature error", () => {
63
- const m1 = loadMaterial(secret());
64
- const m2 = loadMaterial(secret()); // different secret entirely
65
- const cid = mintClientId(m1, {
66
- redirectUris: ["https://client.example.com/cb"],
67
- });
68
- assert.equal(openClientId(m2, cid, 86400), null);
69
- });
70
- test("openClientId returns null when expired", () => {
71
- const m = loadMaterial(secret());
72
- // Mint at t-3600
73
- const past = Math.floor(Date.now() / 1000) - 3600;
74
- const cid = mintClientId(m, {
75
- redirectUris: ["https://client.example.com/cb"],
76
- now: () => past,
77
- });
78
- // TTL = 60s ⇒ expired
79
- assert.equal(openClientId(m, cid, 60), null);
80
- });
81
- test("rotation: cid minted under previous secret still opens", () => {
82
- const s1 = secret();
83
- const s2 = secret();
84
- const mOld = loadMaterial(s1);
85
- const cid = mintClientId(mOld, {
86
- redirectUris: ["https://client.example.com/cb"],
87
- });
88
- // Operator rotated: new is current, old is previous.
89
- const mRotated = loadMaterial(s2, s1);
90
- assert.ok(openClientId(mRotated, cid, 86400));
91
- });
92
- });
93
- // ---------------------------------------------------------------------------
94
- // clientsStore end-to-end across two provider instances (cross-pod)
95
- // ---------------------------------------------------------------------------
96
- describe("clientsStore cross-pod (stateless)", () => {
97
- test("register on pod A ⇒ getClient succeeds on pod B", async () => {
98
- const sharedSecret = secret();
99
- const podA = makeProvider(loadMaterial(sharedSecret));
100
- const podB = makeProvider(loadMaterial(sharedSecret));
101
- // Register on A
102
- const registered = await podA.clientsStore.registerClient({
103
- redirect_uris: ["https://client.example.com/cb"],
104
- token_endpoint_auth_method: "none",
105
- });
106
- assert.ok(looksLikeStatelessClientId(registered.client_id));
107
- assert.deepEqual(registered.redirect_uris, ["https://client.example.com/cb"]);
108
- // Lookup on B — no shared memory, only shared secret
109
- const looked = await podB.clientsStore.getClient(registered.client_id);
110
- assert.ok(looked, "B should resolve the signed client_id");
111
- assert.equal(looked.client_id, registered.client_id);
112
- assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
113
- // token_endpoint_auth_method defaults to "none" for DCR
114
- assert.equal(looked.token_endpoint_auth_method, "none");
115
- });
116
- test("getClient on pod B with different secret rejects the client_id", async () => {
117
- const podA = makeProvider(loadMaterial(secret()));
118
- const podB = makeProvider(loadMaterial(secret())); // different secret
119
- const registered = await podA.clientsStore.registerClient({
120
- redirect_uris: ["https://client.example.com/cb"],
121
- token_endpoint_auth_method: "none",
122
- });
123
- const looked = await podB.clientsStore.getClient(registered.client_id);
124
- assert.equal(looked, undefined);
125
- });
126
- test("non-stateless provider falls back to legacy cache path", async () => {
127
- // Simulating a pod with stateless mode OFF — it should keep working
128
- // against its own in-memory cache.
129
- const provider = makeProvider(null);
130
- const registered = await provider.clientsStore.registerClient({
131
- redirect_uris: ["https://client.example.com/cb"],
132
- token_endpoint_auth_method: "none",
133
- });
134
- // Legacy mode uses UUID client_ids, not stateless v1.cid.*
135
- assert.ok(!looksLikeStatelessClientId(registered.client_id));
136
- const looked = await provider.clientsStore.getClient(registered.client_id);
137
- assert.ok(looked);
138
- assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
139
- });
140
- test("legacy client_id is unchanged in stateless mode (e.g. pre-existing GitLab app uid)", async () => {
141
- // A caller that passes a non-stateless client_id should receive the
142
- // legacy stub so unrelated flows (like token exchange with a pre-registered
143
- // GitLab app) continue to work.
144
- const provider = makeProvider(loadMaterial(secret()));
145
- const looked = await provider.clientsStore.getClient("legacy-app-id");
146
- assert.ok(looked);
147
- assert.equal(looked.client_id, "legacy-app-id");
148
- // Stub has empty redirect_uris — the caller's code path must tolerate this.
149
- assert.deepEqual(looked.redirect_uris, []);
150
- });
151
- test("rotation: client_id minted on pod-old verifies on pod-rotated", async () => {
152
- const s1 = secret();
153
- const s2 = secret();
154
- const podOld = makeProvider(loadMaterial(s1));
155
- const podRotated = makeProvider(loadMaterial(s2, s1));
156
- const registered = await podOld.clientsStore.registerClient({
157
- redirect_uris: ["https://client.example.com/cb"],
158
- token_endpoint_auth_method: "none",
159
- });
160
- const looked = await podRotated.clientsStore.getClient(registered.client_id);
161
- assert.ok(looked);
162
- assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
163
- });
164
- test("long redirect_uri list roundtrips", async () => {
165
- const sharedSecret = secret();
166
- const podA = makeProvider(loadMaterial(sharedSecret));
167
- const podB = makeProvider(loadMaterial(sharedSecret));
168
- const ruris = Array.from({ length: 5 }, (_, i) => `https://client.example.com/cb-${i}`);
169
- const registered = await podA.clientsStore.registerClient({
170
- redirect_uris: ruris,
171
- token_endpoint_auth_method: "none",
172
- });
173
- const looked = await podB.clientsStore.getClient(registered.client_id);
174
- assert.ok(looked);
175
- assert.deepEqual(looked.redirect_uris, ruris);
176
- });
177
- });
@@ -1,328 +0,0 @@
1
- /**
2
- * Unit tests for the stateless token codec.
3
- *
4
- * Covers:
5
- * - secret loading from env (valid, missing, malformed, rotation)
6
- * - HKDF subkey derivation (purpose isolation)
7
- * - sign / verify roundtrip (happy path, tamper, TTL, purpose mismatch)
8
- * - seal / open roundtrip (happy path, tamper, TTL, purpose mismatch)
9
- * - rotation: values minted under previous secret still verify
10
- */
11
- import assert from "node:assert/strict";
12
- import { randomBytes, createHmac } from "node:crypto";
13
- import { describe, test } from "node:test";
14
- import { open, seal, sign, verify, STATELESS_PURPOSES, StatelessCodecError, StatelessConfigError, decodeSecret, deriveSubkey, deriveSubkeys, loadKeyMaterialFromEnv, } from "../../stateless/index.js";
15
- // ---------------------------------------------------------------------------
16
- // Helpers
17
- // ---------------------------------------------------------------------------
18
- /** Generate a 32-byte secret string as an operator would. */
19
- function genSecret() {
20
- return randomBytes(32).toString("base64url");
21
- }
22
- function buildMaterial(current, previous) {
23
- const env = {
24
- OAUTH_STATELESS_SECRET: current ?? genSecret(),
25
- };
26
- if (previous)
27
- env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
28
- const material = loadKeyMaterialFromEnv(true, env);
29
- assert.ok(material, "material should load");
30
- return material;
31
- }
32
- function dummyPayload(now = Math.floor(Date.now() / 1000)) {
33
- return { v: 1, iat: now, hello: "world" };
34
- }
35
- // ---------------------------------------------------------------------------
36
- // loadKeyMaterialFromEnv
37
- // ---------------------------------------------------------------------------
38
- describe("loadKeyMaterialFromEnv", () => {
39
- test("returns null when mode disabled", () => {
40
- // `enabled=false` short-circuits regardless of env contents.
41
- assert.equal(loadKeyMaterialFromEnv(false, {}), null);
42
- assert.equal(loadKeyMaterialFromEnv(false, { OAUTH_STATELESS_SECRET: genSecret() }), null);
43
- });
44
- test("ignores env.OAUTH_STATELESS_MODE — enablement is caller-resolved", () => {
45
- // The loader must not re-parse the raw env var; the resolved config flag
46
- // (which honors the CLI --oauth-stateless-mode) is the single source of
47
- // truth. Regression guard for the silent fallback-to-per-pod bug when
48
- // stateless mode was enabled only via CLI.
49
- const secret = genSecret();
50
- // enabled=true with no env.OAUTH_STATELESS_MODE set → still loads.
51
- const m = loadKeyMaterialFromEnv(true, { OAUTH_STATELESS_SECRET: secret });
52
- assert.ok(m, "loader must not require env.OAUTH_STATELESS_MODE");
53
- // enabled=false with env.OAUTH_STATELESS_MODE=true → still null.
54
- assert.equal(loadKeyMaterialFromEnv(false, {
55
- OAUTH_STATELESS_MODE: "true",
56
- OAUTH_STATELESS_SECRET: secret,
57
- }), null);
58
- });
59
- test("throws when mode enabled but secret missing", () => {
60
- assert.throws(() => loadKeyMaterialFromEnv(true, {}), StatelessConfigError);
61
- });
62
- test("throws on too-short secret", () => {
63
- assert.throws(() => loadKeyMaterialFromEnv(true, {
64
- OAUTH_STATELESS_SECRET: Buffer.from("short").toString("base64url"),
65
- }), StatelessConfigError);
66
- });
67
- test("loads current only", () => {
68
- const m = loadKeyMaterialFromEnv(true, {
69
- OAUTH_STATELESS_SECRET: genSecret(),
70
- });
71
- assert.ok(m);
72
- assert.equal(m.previous, undefined);
73
- });
74
- test("loads current + previous", () => {
75
- const m = loadKeyMaterialFromEnv(true, {
76
- OAUTH_STATELESS_SECRET: genSecret(),
77
- OAUTH_STATELESS_SECRET_PREVIOUS: genSecret(),
78
- });
79
- assert.ok(m);
80
- assert.equal(m.current.length, 32);
81
- assert.equal(m.previous.length, 32);
82
- });
83
- test("rejects malformed base64url previous", () => {
84
- assert.throws(() => loadKeyMaterialFromEnv(true, {
85
- OAUTH_STATELESS_SECRET: genSecret(),
86
- OAUTH_STATELESS_SECRET_PREVIOUS: "!!!not-base64!!!",
87
- }), StatelessConfigError);
88
- });
89
- });
90
- // ---------------------------------------------------------------------------
91
- // decodeSecret direct
92
- // ---------------------------------------------------------------------------
93
- describe("decodeSecret", () => {
94
- test("rejects empty", () => {
95
- assert.throws(() => decodeSecret(" ", "X"), StatelessConfigError);
96
- });
97
- test("accepts exactly 32 bytes", () => {
98
- const s = randomBytes(32).toString("base64url");
99
- assert.equal(decodeSecret(s, "X").length, 32);
100
- });
101
- test("accepts more than 32 bytes", () => {
102
- const s = randomBytes(64).toString("base64url");
103
- assert.equal(decodeSecret(s, "X").length, 64);
104
- });
105
- });
106
- // ---------------------------------------------------------------------------
107
- // deriveSubkey / deriveSubkeys — purpose isolation
108
- // ---------------------------------------------------------------------------
109
- describe("HKDF subkey derivation", () => {
110
- test("same purpose + same secret ⇒ same key", () => {
111
- const secret = genSecret();
112
- const m1 = buildMaterial(secret);
113
- const m2 = buildMaterial(secret);
114
- const k1 = deriveSubkey(m1, "current", STATELESS_PURPOSES.CLIENT_ID);
115
- const k2 = deriveSubkey(m2, "current", STATELESS_PURPOSES.CLIENT_ID);
116
- assert.ok(k1.equals(k2));
117
- });
118
- test("different purposes ⇒ different keys", () => {
119
- const m = buildMaterial();
120
- const kC = deriveSubkey(m, "current", STATELESS_PURPOSES.CLIENT_ID);
121
- const kS = deriveSubkey(m, "current", STATELESS_PURPOSES.SESSION_ID);
122
- const kP = deriveSubkey(m, "current", STATELESS_PURPOSES.PENDING_AUTH);
123
- const kT = deriveSubkey(m, "current", STATELESS_PURPOSES.STORED_TOKENS);
124
- const set = new Set([kC.toString("hex"), kS.toString("hex"), kP.toString("hex"), kT.toString("hex")]);
125
- assert.equal(set.size, 4);
126
- });
127
- test("different master secrets ⇒ different keys", () => {
128
- const m1 = buildMaterial();
129
- const m2 = buildMaterial();
130
- assert.notEqual(deriveSubkey(m1, "current", STATELESS_PURPOSES.CLIENT_ID).toString("hex"), deriveSubkey(m2, "current", STATELESS_PURPOSES.CLIENT_ID).toString("hex"));
131
- });
132
- test("deriveSubkeys returns [current] when no previous", () => {
133
- const m = buildMaterial();
134
- const ks = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID);
135
- assert.equal(ks.length, 1);
136
- assert.equal(ks[0].slot, "current");
137
- });
138
- test("deriveSubkeys returns [current, previous] when rotated", () => {
139
- const m = buildMaterial(genSecret(), genSecret());
140
- const ks = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID);
141
- assert.equal(ks.length, 2);
142
- assert.equal(ks[0].slot, "current");
143
- assert.equal(ks[1].slot, "previous");
144
- assert.notEqual(ks[0].key.toString("hex"), ks[1].key.toString("hex"));
145
- });
146
- });
147
- // ---------------------------------------------------------------------------
148
- // sign / verify (CLIENT_ID purpose)
149
- // ---------------------------------------------------------------------------
150
- describe("sign / verify", () => {
151
- test("happy-path roundtrip", () => {
152
- const m = buildMaterial();
153
- const p = dummyPayload();
154
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, p);
155
- const { payload, slot } = verify(m, STATELESS_PURPOSES.CLIENT_ID, token, {
156
- ttlSeconds: 3600,
157
- });
158
- assert.deepEqual(payload, p);
159
- assert.equal(slot, "current");
160
- assert.match(token, /^v1\.cid\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
161
- });
162
- test("tampered payload fails verify", () => {
163
- const m = buildMaterial();
164
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
165
- const parts = token.split(".");
166
- const tampered = Buffer.from('{"v":1,"iat":1,"hello":"EVIL"}').toString("base64url");
167
- parts[2] = tampered;
168
- const bad = parts.join(".");
169
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_signature");
170
- });
171
- test("tampered signature fails verify", () => {
172
- const m = buildMaterial();
173
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
174
- const parts = token.split(".");
175
- const evilMac = Buffer.alloc(32, 7).toString("base64url");
176
- parts[3] = evilMac;
177
- const bad = parts.join(".");
178
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_signature");
179
- });
180
- test("wrong purpose on verify is rejected", () => {
181
- const m = buildMaterial();
182
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
183
- assert.throws(() => verify(m, STATELESS_PURPOSES.SESSION_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "purpose_mismatch");
184
- });
185
- test("expired token is rejected", () => {
186
- const m = buildMaterial();
187
- const longAgo = Math.floor(Date.now() / 1000) - 10_000;
188
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload(longAgo));
189
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "expired");
190
- });
191
- test("future iat beyond skew rejected", () => {
192
- const m = buildMaterial();
193
- const future = Math.floor(Date.now() / 1000) + 3600;
194
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload(future));
195
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "future_iat");
196
- });
197
- test("malformed prefix rejected", () => {
198
- const m = buildMaterial();
199
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, "not-a-token", { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "malformed");
200
- });
201
- test("wrong version rejected", () => {
202
- const m = buildMaterial();
203
- const token = sign(m, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
204
- const bad = token.replace(/^v1\./, "v9.");
205
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, bad, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "unknown_version");
206
- });
207
- test("rotation: verify with previous secret succeeds", () => {
208
- const oldSecret = genSecret();
209
- const newSecret = genSecret();
210
- const minter = buildMaterial(oldSecret); // minted under old
211
- const token = sign(minter, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
212
- // Verifier has rotated: new is current, old is previous.
213
- const verifier = buildMaterial(newSecret, oldSecret);
214
- const { slot } = verify(verifier, STATELESS_PURPOSES.CLIENT_ID, token, {
215
- ttlSeconds: 3600,
216
- });
217
- assert.equal(slot, "previous");
218
- });
219
- test("cross-pod: two independently-loaded materials with same secret agree", () => {
220
- const secret = genSecret();
221
- const podA = buildMaterial(secret);
222
- const podB = buildMaterial(secret);
223
- const token = sign(podA, STATELESS_PURPOSES.CLIENT_ID, dummyPayload());
224
- const { payload } = verify(podB, STATELESS_PURPOSES.CLIENT_ID, token, {
225
- ttlSeconds: 3600,
226
- });
227
- assert.equal(payload.hello, "world");
228
- });
229
- });
230
- // ---------------------------------------------------------------------------
231
- // seal / open (SESSION_ID purpose)
232
- // ---------------------------------------------------------------------------
233
- describe("seal / open", () => {
234
- test("happy-path roundtrip", () => {
235
- const m = buildMaterial();
236
- const p = dummyPayload();
237
- const token = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
238
- const { payload, slot } = open(m, STATELESS_PURPOSES.SESSION_ID, token, {
239
- ttlSeconds: 3600,
240
- });
241
- assert.deepEqual(payload, p);
242
- assert.equal(slot, "current");
243
- assert.match(token, /^v1\.sid\.[A-Za-z0-9_-]+$/);
244
- });
245
- test("each seal uses a fresh nonce (different ciphertexts)", () => {
246
- const m = buildMaterial();
247
- const p = dummyPayload();
248
- const t1 = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
249
- const t2 = seal(m, STATELESS_PURPOSES.SESSION_ID, p);
250
- assert.notEqual(t1, t2);
251
- });
252
- test("tampered ciphertext fails open", () => {
253
- const m = buildMaterial();
254
- const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
255
- // Flip a bit in the middle of the blob.
256
- const parts = token.split(".");
257
- const blob = Buffer.from(parts[2], "base64url");
258
- const midIndex = Math.floor(blob.length / 2);
259
- blob[midIndex] ^= 0xff;
260
- parts[2] = blob.toString("base64url");
261
- const bad = parts.join(".");
262
- assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, bad, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_ciphertext");
263
- });
264
- test("wrong purpose rejected at parse", () => {
265
- const m = buildMaterial();
266
- const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
267
- assert.throws(() => open(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "purpose_mismatch");
268
- });
269
- test("forging a sealed value for wrong purpose fails AAD check", () => {
270
- // Build a token under SESSION_ID but then relabel as PENDING_AUTH.
271
- const m = buildMaterial();
272
- const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
273
- const relabeled = token.replace(/^v1\.sid\./, "v1.ps.");
274
- assert.throws(() => open(m, STATELESS_PURPOSES.PENDING_AUTH, relabeled, { ttlSeconds: 3600 }),
275
- // AAD mismatch surfaces as bad_ciphertext (AEAD integrity failure).
276
- (err) => err instanceof StatelessCodecError && err.reason === "bad_ciphertext");
277
- });
278
- test("expired sealed value rejected", () => {
279
- const m = buildMaterial();
280
- const longAgo = Math.floor(Date.now() / 1000) - 10_000;
281
- const token = seal(m, STATELESS_PURPOSES.SESSION_ID, dummyPayload(longAgo));
282
- assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, token, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError && err.reason === "expired");
283
- });
284
- test("malformed blob (too short) rejected", () => {
285
- const m = buildMaterial();
286
- const tooShort = "v1.sid." + Buffer.alloc(4).toString("base64url");
287
- assert.throws(() => open(m, STATELESS_PURPOSES.SESSION_ID, tooShort, { ttlSeconds: 60 }), (err) => err instanceof StatelessCodecError &&
288
- (err.reason === "malformed" || err.reason === "bad_ciphertext"));
289
- });
290
- test("rotation: sealed value minted under previous secret still opens", () => {
291
- const oldSecret = genSecret();
292
- const newSecret = genSecret();
293
- const minter = buildMaterial(oldSecret);
294
- const token = seal(minter, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
295
- const verifier = buildMaterial(newSecret, oldSecret);
296
- const { slot } = open(verifier, STATELESS_PURPOSES.SESSION_ID, token, {
297
- ttlSeconds: 3600,
298
- });
299
- assert.equal(slot, "previous");
300
- });
301
- test("two pods sharing secret interop", () => {
302
- const secret = genSecret();
303
- const podA = buildMaterial(secret);
304
- const podB = buildMaterial(secret);
305
- const token = seal(podA, STATELESS_PURPOSES.SESSION_ID, dummyPayload());
306
- const { payload } = open(podB, STATELESS_PURPOSES.SESSION_ID, token, {
307
- ttlSeconds: 3600,
308
- });
309
- assert.equal(payload.hello, "world");
310
- });
311
- });
312
- // ---------------------------------------------------------------------------
313
- // Mixed purpose / payload-shape validation
314
- // ---------------------------------------------------------------------------
315
- describe("payload schema validation", () => {
316
- test("payload without v rejected", () => {
317
- const m = buildMaterial();
318
- // Hand-craft a signed token with bogus shape.
319
- const badPayload = Buffer.from(JSON.stringify({ iat: Math.floor(Date.now() / 1000) }));
320
- // Compute matching HMAC so we reach the shape check, not the sig check.
321
- const [{ key }] = deriveSubkeys(m, STATELESS_PURPOSES.CLIENT_ID).filter((k) => k.slot === "current");
322
- const mac = createHmac("sha256", key)
323
- .update(Buffer.concat([Buffer.from("cid"), Buffer.from([0]), badPayload]))
324
- .digest();
325
- const token = `v1.cid.${badPayload.toString("base64url")}.${mac.toString("base64url")}`;
326
- assert.throws(() => verify(m, STATELESS_PURPOSES.CLIENT_ID, token, { ttlSeconds: 3600 }), (err) => err instanceof StatelessCodecError && err.reason === "bad_schema");
327
- });
328
- });
@@ -1,149 +0,0 @@
1
- /**
2
- * Regression tests for OAUTH_STATELESS_SESSION_TTL_SECONDS parsing.
3
- *
4
- * Maintainer feedback (zereight, PR #442):
5
- * "This can still disable stateless session expiry when
6
- * OAUTH_STATELESS_SESSION_TTL_SECONDS is unset and SESSION_TIMEOUT_SECONDS
7
- * is invalid. In that state, this fallback becomes NaN; _intEnv() returns
8
- * the fallback directly when the stateless-specific value is missing, so
9
- * OAUTH_STATELESS_SESSION_TTL_SECONDS becomes NaN instead of falling back
10
- * to a safe positive default."
11
- *
12
- * The fix validates both the direct env/CLI value and the fallback inside
13
- * _intEnv, and also sanitizes SESSION_TIMEOUT_SECONDS itself through the
14
- * same helper so its downstream consumers always see a finite positive
15
- * integer.
16
- *
17
- * config.ts reads process.env / process.argv at module load, so these
18
- * tests use a child process per scenario to control the environment.
19
- */
20
- import assert from "node:assert/strict";
21
- import { execFileSync } from "node:child_process";
22
- import * as path from "node:path";
23
- import * as url from "node:url";
24
- import { describe, test } from "node:test";
25
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
26
- const CONFIG_PATH = path.resolve(__dirname, "../../config.ts");
27
- /**
28
- * Spawn a child Node process that imports config.ts with the given env
29
- * overrides, and returns a structured snapshot of the parsed numeric
30
- * config values. We pipe a tiny evaluator script through stdin (`--eval`
31
- * with tsx via `--import`) to keep the test hermetic.
32
- */
33
- function loadConfig(env) {
34
- const script = `
35
- import(${JSON.stringify(url.pathToFileURL(CONFIG_PATH).href)}).then((m) => {
36
- const out = {
37
- SESSION_TIMEOUT_SECONDS: m.SESSION_TIMEOUT_SECONDS,
38
- OAUTH_STATELESS_SESSION_TTL_SECONDS: m.OAUTH_STATELESS_SESSION_TTL_SECONDS,
39
- OAUTH_STATELESS_CLIENT_TTL_SECONDS: m.OAUTH_STATELESS_CLIENT_TTL_SECONDS,
40
- PORT: m.PORT,
41
- };
42
- process.stdout.write(JSON.stringify(out));
43
- }).catch((err) => {
44
- process.stderr.write(String(err && err.stack || err));
45
- process.exit(2);
46
- });
47
- `;
48
- // Strip undefined entries so they truly aren't set in the child's env.
49
- const childEnv = {
50
- // Keep PATH / basic vars so tsx can spawn.
51
- ...process.env,
52
- };
53
- // Reset anything that could interfere with the test even if our caller
54
- // didn't set it — make every variable start clean.
55
- delete childEnv.SESSION_TIMEOUT_SECONDS;
56
- delete childEnv.OAUTH_STATELESS_SESSION_TTL_SECONDS;
57
- delete childEnv.OAUTH_STATELESS_CLIENT_TTL_SECONDS;
58
- delete childEnv.PORT;
59
- // Apply the per-test overrides last.
60
- for (const [k, v] of Object.entries(env)) {
61
- if (v === undefined) {
62
- delete childEnv[k];
63
- }
64
- else {
65
- childEnv[k] = v;
66
- }
67
- }
68
- const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", script], {
69
- env: childEnv,
70
- encoding: "utf8",
71
- stdio: ["ignore", "pipe", "pipe"],
72
- });
73
- return JSON.parse(stdout);
74
- }
75
- describe("config.ts TTL parsing — finite-positive guards", () => {
76
- test("happy path: both TTLs default to 3600 when unset", () => {
77
- const cfg = loadConfig({});
78
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
79
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
80
- });
81
- test("OAUTH_STATELESS_SESSION_TTL_SECONDS honors its own env value", () => {
82
- const cfg = loadConfig({
83
- OAUTH_STATELESS_SESSION_TTL_SECONDS: "7200",
84
- });
85
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 7200);
86
- });
87
- test("OAUTH_STATELESS_SESSION_TTL_SECONDS inherits valid SESSION_TIMEOUT_SECONDS", () => {
88
- const cfg = loadConfig({
89
- SESSION_TIMEOUT_SECONDS: "1800",
90
- });
91
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 1800);
92
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 1800);
93
- });
94
- test("invalid SESSION_TIMEOUT_SECONDS must not poison OAUTH_STATELESS_SESSION_TTL_SECONDS (NaN regression)", () => {
95
- // Regression for zereight's P-level PR review feedback. Before the
96
- // fix, _intEnv returned NaN verbatim from the fallback when the
97
- // stateless-specific var was unset, which silently disabled TTL
98
- // checks in checkIat (`ttlSec > 0` is false for NaN).
99
- const cfg = loadConfig({
100
- SESSION_TIMEOUT_SECONDS: "not-a-number",
101
- });
102
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, "OAUTH_STATELESS_SESSION_TTL_SECONDS must fall back to the hardcoded 3600 default when SESSION_TIMEOUT_SECONDS is invalid");
103
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600, "SESSION_TIMEOUT_SECONDS must sanitize its own input too");
104
- assert.ok(Number.isFinite(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS), "OAUTH_STATELESS_SESSION_TTL_SECONDS must be a finite number");
105
- assert.ok(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS > 0, "OAUTH_STATELESS_SESSION_TTL_SECONDS must be strictly positive");
106
- });
107
- test("zero and negative SESSION_TIMEOUT_SECONDS fall back to the safe default", () => {
108
- for (const badValue of ["0", "-1", "-3600"]) {
109
- const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: badValue });
110
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600, `SESSION_TIMEOUT_SECONDS=${badValue} should sanitize to default`);
111
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, `OAUTH_STATELESS_SESSION_TTL_SECONDS should inherit the sanitized default from SESSION_TIMEOUT_SECONDS=${badValue}`);
112
- }
113
- });
114
- test("garbage OAUTH_STATELESS_SESSION_TTL_SECONDS falls back to SESSION_TIMEOUT_SECONDS (when valid) or safe default", () => {
115
- const cfg = loadConfig({
116
- OAUTH_STATELESS_SESSION_TTL_SECONDS: "garbage",
117
- SESSION_TIMEOUT_SECONDS: "1800",
118
- });
119
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 1800, "invalid direct value should defer to the valid fallback");
120
- const cfg2 = loadConfig({
121
- OAUTH_STATELESS_SESSION_TTL_SECONDS: "garbage",
122
- SESSION_TIMEOUT_SECONDS: "also-garbage",
123
- });
124
- assert.equal(cfg2.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600, "both invalid should hit the hardcoded safe default");
125
- });
126
- test("other _intEnv consumers remain unaffected (client TTL default)", () => {
127
- // Guards against an accidental over-broad change to _intEnv.
128
- const cfg = loadConfig({});
129
- assert.equal(cfg.OAUTH_STATELESS_CLIENT_TTL_SECONDS, 86_400);
130
- });
131
- test("PORT uses the hardened _intEnv and rejects invalid values", () => {
132
- const cfg = loadConfig({ PORT: "not-a-port" });
133
- assert.equal(cfg.PORT, 3002, "invalid PORT should fall back to the default rather than NaN");
134
- });
135
- test("empty-string SESSION_TIMEOUT_SECONDS falls back to the safe default", () => {
136
- // getConfig returns the value only when truthy; empty string is falsy
137
- // and picks the default branch. This guards against regressions in the
138
- // "raw empty" → "use defaultValue" short-circuit at getConfig.
139
- const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: "" });
140
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
141
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
142
- });
143
- test("whitespace-only SESSION_TIMEOUT_SECONDS also falls back", () => {
144
- // parseInt(" ", 10) is NaN, so the _intEnv guard must catch it.
145
- const cfg = loadConfig({ SESSION_TIMEOUT_SECONDS: " " });
146
- assert.equal(cfg.SESSION_TIMEOUT_SECONDS, 3600);
147
- assert.equal(cfg.OAUTH_STATELESS_SESSION_TTL_SECONDS, 3600);
148
- });
149
- });