@zereight/mcp-gitlab 2.1.69 → 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 (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  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/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  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-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. 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
- });