@zereight/mcp-gitlab 2.1.5 → 2.1.6

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.
@@ -48,6 +48,9 @@ import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server
48
48
  import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
49
49
  import { randomUUID, randomBytes, createHash } from "node:crypto";
50
50
  import { pino } from "pino";
51
+ import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
52
+ import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
53
+ import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/stored-tokens.js";
51
54
  const logger = pino({ name: "gitlab-mcp-oauth-proxy" });
52
55
  // ---------------------------------------------------------------------------
53
56
  // GitLab OAuth Server Provider
@@ -120,7 +123,11 @@ class GitLabOAuthServerProvider {
120
123
  _callbackUrl;
121
124
  _pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
122
125
  _storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
123
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
126
+ // Stateless mode (optional). When set, DCR and callback-proxy state are
127
+ // serialised into opaque OAuth values and the in-memory caches above are
128
+ // bypassed. Enabled independently of callback-proxy mode.
129
+ _stateless;
130
+ constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
124
131
  this._gitlabBaseUrl = gitlabBaseUrl;
125
132
  this._gitlabAppId = gitlabAppId;
126
133
  this._resourceName = resourceName;
@@ -132,19 +139,46 @@ class GitLabOAuthServerProvider {
132
139
  : REQUIRED_GITLAB_SCOPES_RW;
133
140
  this._callbackProxyEnabled = callbackProxyEnabled;
134
141
  this._callbackUrl = callbackUrl;
142
+ this._stateless = stateless;
135
143
  if (callbackProxyEnabled && !callbackUrl) {
136
144
  throw new Error("callbackUrl is required when callbackProxyEnabled is true");
137
145
  }
138
146
  if (callbackProxyEnabled) {
139
147
  logger.info(`Callback proxy mode enabled — fixed callback URL: ${callbackUrl}`);
140
148
  }
149
+ if (stateless) {
150
+ logger.info(`Stateless mode enabled (client_id TTL: ${stateless.clientTtlSeconds}s, ` +
151
+ `pending TTL: ${stateless.pendingTtlSeconds}s, ` +
152
+ `stored TTL: ${stateless.storedTtlSeconds}s)`);
153
+ }
141
154
  }
142
155
  // ---- Client store (local DCR) ------------------------------------------
143
156
  get clientsStore() {
144
157
  const cache = this._clientCache;
145
158
  const resourceName = this._resourceName;
159
+ const stateless = this._stateless;
146
160
  return {
147
161
  getClient: async (clientId) => {
162
+ // Stateless path: a signed client_id carries the registration.
163
+ // If verification succeeds, reconstruct the OAuthClientInformationFull.
164
+ if (stateless && looksLikeStatelessClientId(clientId)) {
165
+ const payload = openClientId(stateless.material, clientId, stateless.clientTtlSeconds);
166
+ if (!payload) {
167
+ logger.warn(`DCR: stateless client_id rejected (bad signature or expired)`);
168
+ // Mimic legacy behaviour: return a stub so the SDK surfaces the
169
+ // standard InvalidClientError path. We return null to let the SDK
170
+ // handler emit a proper OAuth error.
171
+ return undefined;
172
+ }
173
+ return {
174
+ client_id: clientId,
175
+ client_id_issued_at: payload.iat,
176
+ redirect_uris: payload.ruris,
177
+ token_endpoint_auth_method: "none",
178
+ grant_types: payload.gt ?? ["authorization_code"],
179
+ client_name: payload.cn ?? resourceName,
180
+ };
181
+ }
148
182
  const cached = cache.get(clientId);
149
183
  if (cached)
150
184
  return cached;
@@ -157,17 +191,40 @@ class GitLabOAuthServerProvider {
157
191
  };
158
192
  },
159
193
  registerClient: async (client) => {
194
+ const grantTypes = client.grant_types ?? ["authorization_code"];
195
+ const redirectUris = client.redirect_uris ?? [];
196
+ const clientName = client.client_name
197
+ ? `${client.client_name} via ${resourceName}`
198
+ : resourceName;
199
+ // Stateless path: mint a signed client_id and return the registration
200
+ // without touching the in-memory cache.
201
+ if (stateless) {
202
+ const issuedAt = Math.floor(Date.now() / 1000);
203
+ const clientId = mintClientId(stateless.material, {
204
+ redirectUris,
205
+ grantTypes,
206
+ clientName,
207
+ });
208
+ const registered = {
209
+ client_id: clientId,
210
+ client_id_issued_at: issuedAt,
211
+ redirect_uris: redirectUris,
212
+ token_endpoint_auth_method: "none",
213
+ grant_types: grantTypes,
214
+ client_name: clientName,
215
+ };
216
+ logger.info(`DCR (stateless): issued signed client_id (name: ${clientName}, ruris: ${redirectUris.length})`);
217
+ return registered;
218
+ }
160
219
  // Generate a virtual client_id; all real OAuth operations use _gitlabAppId.
161
220
  const virtualClientId = randomUUID();
162
221
  const registered = {
163
222
  client_id: virtualClientId,
164
223
  client_id_issued_at: Math.floor(Date.now() / 1000),
165
- redirect_uris: client.redirect_uris ?? [],
224
+ redirect_uris: redirectUris,
166
225
  token_endpoint_auth_method: "none",
167
- grant_types: client.grant_types ?? ["authorization_code"],
168
- client_name: client.client_name
169
- ? `${client.client_name} via ${resourceName}`
170
- : resourceName,
226
+ grant_types: grantTypes,
227
+ client_name: clientName,
171
228
  };
172
229
  cache.set(virtualClientId, registered);
173
230
  logger.info(`DCR: registered virtual client ${virtualClientId} (name: ${registered.client_name})`);
@@ -191,17 +248,30 @@ class GitLabOAuthServerProvider {
191
248
  const proxyCodeChallenge = createHash("sha256")
192
249
  .update(proxyCodeVerifier)
193
250
  .digest("base64url");
194
- // Use a unique state to correlate the callback
195
- const proxyState = randomUUID();
196
- // Store the client's original params so /callback can redirect back
197
- this._pendingAuth.set(proxyState, {
198
- clientId: client.client_id,
199
- clientRedirectUri: params.redirectUri,
200
- clientState: params.state,
201
- clientCodeChallenge: params.codeChallenge,
202
- proxyCodeVerifier,
203
- createdAt: Date.now(),
204
- });
251
+ // Correlate the callback via either a sealed state (stateless mode) or
252
+ // a random UUID stored in the pendingAuth LRU (legacy mode).
253
+ const stateless = this._stateless;
254
+ const proxyState = stateless
255
+ ? mintPendingAuthState(stateless.material, {
256
+ clientId: client.client_id,
257
+ clientRedirectUri: params.redirectUri,
258
+ clientState: params.state,
259
+ clientCodeChallenge: params.codeChallenge,
260
+ proxyCodeVerifier,
261
+ })
262
+ : randomUUID();
263
+ if (!stateless) {
264
+ // Store the client's original params so /callback can redirect back.
265
+ // Stateless mode carries these inside proxyState itself.
266
+ this._pendingAuth.set(proxyState, {
267
+ clientId: client.client_id,
268
+ clientRedirectUri: params.redirectUri,
269
+ clientState: params.state,
270
+ clientCodeChallenge: params.codeChallenge,
271
+ proxyCodeVerifier,
272
+ createdAt: Date.now(),
273
+ });
274
+ }
205
275
  const searchParams = new URLSearchParams({
206
276
  client_id: this._gitlabAppId,
207
277
  response_type: "code",
@@ -246,19 +316,44 @@ class GitLabOAuthServerProvider {
246
316
  if (this._callbackProxyEnabled) {
247
317
  // --- Callback proxy mode ---
248
318
  // The authorizationCode is a proxy code we generated in handleCallback().
249
- // Look up the stored tokens by proxy code.
250
- const entry = this._storedTokens.get(authorizationCode);
251
- if (!entry) {
252
- throw new ServerError("Invalid or expired authorization code");
319
+ // It is either a sealed token (stateless mode) or a random UUID that
320
+ // keys into the _storedTokens LRU (legacy mode).
321
+ const stateless = this._stateless;
322
+ let entry = null;
323
+ if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
324
+ const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
325
+ if (!payload) {
326
+ throw new ServerError("Invalid or expired authorization code");
327
+ }
328
+ entry = {
329
+ tokens: payload.t,
330
+ clientId: payload.cid,
331
+ clientCodeChallenge: payload.ccc,
332
+ clientRedirectUri: payload.cru,
333
+ };
334
+ // NOTE: Stateless mode cannot enforce one-time use without a shared
335
+ // store. Replay is mitigated by short TTL + client PKCE verification
336
+ // below (attacker needs the code_verifier). Documented in
337
+ // stateless/stored-tokens.ts.
253
338
  }
254
- // Check TTL before consuming — expired entries can't be retried anyway,
255
- // but we give a specific error so the client knows to restart the flow.
256
- if (Date.now() - entry.createdAt > PENDING_AUTH_TTL_MS) {
339
+ else {
340
+ const lru = this._storedTokens.get(authorizationCode);
341
+ if (!lru) {
342
+ throw new ServerError("Invalid or expired authorization code");
343
+ }
344
+ if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
345
+ this._storedTokens.delete(authorizationCode);
346
+ throw new ServerError("Authorization code expired — please restart the OAuth flow");
347
+ }
348
+ // One-time use: delete after validation
257
349
  this._storedTokens.delete(authorizationCode);
258
- throw new ServerError("Authorization code expired — please restart the OAuth flow");
350
+ entry = {
351
+ tokens: lru.tokens,
352
+ clientId: lru.clientId,
353
+ clientCodeChallenge: lru.clientCodeChallenge,
354
+ clientRedirectUri: lru.clientRedirectUri,
355
+ };
259
356
  }
260
- // One-time use: delete after validation
261
- this._storedTokens.delete(authorizationCode);
262
357
  // Bind the proxy code to the client and redirect_uri that initiated
263
358
  // /authorize, preserving the normal OAuth authorization-code invariant.
264
359
  if (client.client_id !== entry.clientId) {
@@ -372,16 +467,42 @@ class GitLabOAuthServerProvider {
372
467
  res.status(400).send("Missing code or state parameter");
373
468
  return;
374
469
  }
375
- // Look up the pending auth transaction
376
- const pending = this._pendingAuth.getAndDelete(state);
377
- if (!pending) {
378
- res.status(400).send("Unknown or expired state parameter");
379
- return;
470
+ // Look up the pending auth transaction. The sealed-state path carries
471
+ // the transaction inline; the legacy path fetches it from the LRU.
472
+ // Both produce the same normalized shape below.
473
+ const stateless = this._stateless;
474
+ let pending = null;
475
+ if (stateless && looksLikeStatelessState(state)) {
476
+ const payload = openPendingAuthState(stateless.material, state, stateless.pendingTtlSeconds);
477
+ if (!payload) {
478
+ res.status(400).send("Unknown or expired state parameter");
479
+ return;
480
+ }
481
+ pending = {
482
+ clientId: payload.cid,
483
+ clientRedirectUri: payload.cru,
484
+ clientState: payload.cs,
485
+ clientCodeChallenge: payload.ccc,
486
+ proxyCodeVerifier: payload.pcv,
487
+ };
380
488
  }
381
- // Check TTL
382
- if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
383
- res.status(400).send("Authorization request expired");
384
- return;
489
+ else {
490
+ const lru = this._pendingAuth.getAndDelete(state);
491
+ if (!lru) {
492
+ res.status(400).send("Unknown or expired state parameter");
493
+ return;
494
+ }
495
+ if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
496
+ res.status(400).send("Authorization request expired");
497
+ return;
498
+ }
499
+ pending = {
500
+ clientId: lru.clientId,
501
+ clientRedirectUri: lru.clientRedirectUri,
502
+ clientState: lru.clientState,
503
+ clientCodeChallenge: lru.clientCodeChallenge,
504
+ proxyCodeVerifier: lru.proxyCodeVerifier,
505
+ };
385
506
  }
386
507
  // Exchange the GitLab auth code for tokens using the proxy's PKCE verifier
387
508
  try {
@@ -404,15 +525,26 @@ class GitLabOAuthServerProvider {
404
525
  return;
405
526
  }
406
527
  const tokens = OAuthTokensSchema.parse(await tokenResponse.json());
407
- // Generate a proxy auth code for the MCP client
408
- const proxyCode = randomUUID();
409
- this._storedTokens.set(proxyCode, {
410
- tokens,
411
- clientId: pending.clientId,
412
- clientCodeChallenge: pending.clientCodeChallenge,
413
- clientRedirectUri: pending.clientRedirectUri,
414
- createdAt: Date.now(),
415
- });
528
+ // Generate a proxy auth code for the MCP client. Sealed in stateless
529
+ // mode; random UUID + LRU entry in legacy mode.
530
+ const proxyCode = stateless
531
+ ? mintStoredTokensCode(stateless.material, {
532
+ tokens,
533
+ clientId: pending.clientId,
534
+ clientRedirectUri: pending.clientRedirectUri,
535
+ clientCodeChallenge: pending.clientCodeChallenge,
536
+ })
537
+ : (() => {
538
+ const id = randomUUID();
539
+ this._storedTokens.set(id, {
540
+ tokens,
541
+ clientId: pending.clientId,
542
+ clientCodeChallenge: pending.clientCodeChallenge,
543
+ clientRedirectUri: pending.clientRedirectUri,
544
+ createdAt: Date.now(),
545
+ });
546
+ return id;
547
+ })();
416
548
  // Redirect to the MCP client's original callback URL
417
549
  const clientCallback = new URL(pending.clientRedirectUri);
418
550
  clientCallback.searchParams.set("code", proxyCode);
@@ -462,7 +594,10 @@ class GitLabOAuthServerProvider {
462
594
  * Only ONE fixed callback URL needs to be registered with GitLab.
463
595
  * @param callbackUrl The fixed callback URL (e.g. https://mcp.example.com/callback).
464
596
  * Required when callbackProxyEnabled is true.
597
+ * @param stateless Optional stateless-mode options. When set, DCR and later
598
+ * callback-proxy state is encoded into opaque OAuth values
599
+ * instead of an in-memory cache, enabling multi-pod deploys.
465
600
  */
466
- export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
467
- return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl);
601
+ export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
602
+ return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl, stateless);
468
603
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * S1 — signed `client_id` for Dynamic Client Registration.
3
+ *
4
+ * The DCR entry is serialised into the `client_id` itself so that any pod
5
+ * holding the same OAUTH_STATELESS_SECRET can reconstruct the registered
6
+ * client record without a shared cache.
7
+ *
8
+ * Uses the signed (HMAC) format. The payload is public (redirect URIs,
9
+ * grant types, client name) and does not require encryption.
10
+ */
11
+ import { randomBytes } from "node:crypto";
12
+ import { sign, verify } from "./codec.js";
13
+ import { StatelessCodecError } from "./errors.js";
14
+ import { STATELESS_PURPOSES, } from "./types.js";
15
+ /**
16
+ * Mint a signed client_id that carries the DCR registration.
17
+ *
18
+ * The returned string is opaque to the MCP client and can be of any length
19
+ * within standard OAuth bounds. In practice ≤ 2 KB for typical inputs.
20
+ */
21
+ export function mintClientId(material, input) {
22
+ const iat = input.now ? input.now() : Math.floor(Date.now() / 1000);
23
+ // 16 bytes of entropy ⇒ negligible collision probability even across
24
+ // arbitrarily many DCR registrations. Kept at 16 to keep the client_id
25
+ // short on the wire.
26
+ const payload = {
27
+ v: 1,
28
+ iat,
29
+ n: randomBytes(16).toString("base64url"),
30
+ ruris: input.redirectUris,
31
+ };
32
+ if (input.grantTypes && input.grantTypes.length > 0)
33
+ payload.gt = input.grantTypes;
34
+ if (input.clientName)
35
+ payload.cn = input.clientName;
36
+ return sign(material, STATELESS_PURPOSES.CLIENT_ID, payload);
37
+ }
38
+ /**
39
+ * Verify a signed client_id and return the decoded payload.
40
+ *
41
+ * Returns null on any verification failure (malformed, bad sig, expired, …).
42
+ * Callers translate `null` to the SDK's InvalidClientError.
43
+ */
44
+ export function openClientId(material, clientId, ttlSeconds, now) {
45
+ try {
46
+ const { payload } = verify(material, STATELESS_PURPOSES.CLIENT_ID, clientId, { ttlSeconds, now });
47
+ if (!Array.isArray(payload.ruris) ||
48
+ payload.ruris.some((u) => typeof u !== "string") ||
49
+ typeof payload.n !== "string" ||
50
+ payload.n.length === 0) {
51
+ return null;
52
+ }
53
+ return payload;
54
+ }
55
+ catch (err) {
56
+ if (err instanceof StatelessCodecError)
57
+ return null;
58
+ throw err;
59
+ }
60
+ }
61
+ /**
62
+ * Utility for callers: heuristically detect whether a string looks like a
63
+ * stateless client_id. Used to skip stateless decoding for legacy client_ids
64
+ * that might still be in circulation (e.g. the GitLab app UID).
65
+ */
66
+ export function looksLikeStatelessClientId(value) {
67
+ return value.startsWith("v1.cid.");
68
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Wire-format codec for stateless tokens.
3
+ *
4
+ * Two formats:
5
+ * signed: v1.<purpose>.<b64url(payload)>.<b64url(hmac)>
6
+ * sealed: v1.<purpose>.<b64url(nonce || ciphertext || tag)>
7
+ *
8
+ * The "signed" form is used when the payload is not confidential (DCR
9
+ * client_id). The "sealed" form is used whenever the payload contains secrets
10
+ * (proxy code verifier, GitLab access token, etc.).
11
+ *
12
+ * Verification is rotation-aware: a value minted under the previous secret
13
+ * still opens/verifies until the operator removes OAUTH_STATELESS_SECRET_PREVIOUS.
14
+ */
15
+ import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from "node:crypto";
16
+ import { StatelessCodecError } from "./errors.js";
17
+ import { deriveSubkeys, hmacSha256 } from "./secret.js";
18
+ import { STATELESS_PURPOSES, STATELESS_VERSION, } from "./types.js";
19
+ const AES_ALG = "aes-256-gcm";
20
+ const NONCE_LEN = 12;
21
+ const TAG_LEN = 16;
22
+ // Hard cap on accepted token length — prevents pathological inputs. 64 KiB is
23
+ // far larger than any legitimate payload we mint (session tokens are ≤2 KiB).
24
+ const MAX_INPUT_LEN = 64 * 1024;
25
+ // Allowance for clock skew when checking `iat` against "now". If a minter's
26
+ // clock is a few seconds ahead of the verifier's, refuse only when the skew
27
+ // is meaningful.
28
+ const IAT_FUTURE_SKEW_SEC = 60;
29
+ // ──────────────────────────────────────────────────────────────────────────
30
+ // Base64url helpers — Node's Buffer handles base64url natively, but we add a
31
+ // tiny wrapper so the call sites read cleanly and errors are captured.
32
+ // ──────────────────────────────────────────────────────────────────────────
33
+ function b64urlEncode(buf) {
34
+ return buf.toString("base64url");
35
+ }
36
+ function b64urlDecode(s, purpose) {
37
+ try {
38
+ // base64url accepts unpadded input; reject anything outside [A-Za-z0-9_-]
39
+ // to keep the surface tight.
40
+ if (!/^[A-Za-z0-9_-]*$/.test(s)) {
41
+ throw new StatelessCodecError("bad_base64", purpose);
42
+ }
43
+ return Buffer.from(s, "base64url");
44
+ }
45
+ catch (err) {
46
+ if (err instanceof StatelessCodecError)
47
+ throw err;
48
+ throw new StatelessCodecError("bad_base64", purpose);
49
+ }
50
+ }
51
+ function canonicalJson(value) {
52
+ // JSON.stringify is stable for the narrow payload shapes we use (fixed keys,
53
+ // no Maps/Sets). Keeping it simple is safer than introducing a canonicaliser
54
+ // dependency; all sign/verify happens within this process family.
55
+ return Buffer.from(JSON.stringify(value));
56
+ }
57
+ function parseJson(buf, purpose) {
58
+ try {
59
+ return JSON.parse(buf.toString("utf8"));
60
+ }
61
+ catch {
62
+ throw new StatelessCodecError("bad_json", purpose);
63
+ }
64
+ }
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+ // Versioning & purpose tags
67
+ // ──────────────────────────────────────────────────────────────────────────
68
+ function splitToken(token, purpose) {
69
+ if (!token || token.length > MAX_INPUT_LEN) {
70
+ throw new StatelessCodecError("malformed", purpose);
71
+ }
72
+ const parts = token.split(".");
73
+ if (parts.length < 3) {
74
+ throw new StatelessCodecError("malformed", purpose);
75
+ }
76
+ if (parts[0] !== STATELESS_VERSION) {
77
+ throw new StatelessCodecError("unknown_version", purpose);
78
+ }
79
+ if (parts[1] !== purpose) {
80
+ throw new StatelessCodecError("purpose_mismatch", purpose);
81
+ }
82
+ return parts;
83
+ }
84
+ function nowSec(override) {
85
+ return override ? override() : Math.floor(Date.now() / 1000);
86
+ }
87
+ /**
88
+ * Mint a signed token. Payload must carry `iat` (the mint helper sets it).
89
+ */
90
+ export function sign(material, purpose, payload) {
91
+ const [{ key }] = deriveSubkeys(material, purpose).filter(k => k.slot === "current");
92
+ const payloadBuf = canonicalJson(payload);
93
+ const mac = hmacSha256(key, Buffer.concat([Buffer.from(purpose), Buffer.from([0]), payloadBuf]));
94
+ return `${STATELESS_VERSION}.${purpose}.${b64urlEncode(payloadBuf)}.${b64urlEncode(mac)}`;
95
+ }
96
+ /**
97
+ * Verify a signed token and return the decoded payload.
98
+ *
99
+ * Result is augmented with the key slot that accepted the MAC, for metrics.
100
+ */
101
+ export function verify(material, purpose, token, opts) {
102
+ const parts = splitToken(token, purpose);
103
+ if (parts.length !== 4) {
104
+ throw new StatelessCodecError("malformed", purpose);
105
+ }
106
+ const payloadBuf = b64urlDecode(parts[2], purpose);
107
+ const macBuf = b64urlDecode(parts[3], purpose);
108
+ const keys = deriveSubkeys(material, purpose);
109
+ if (keys.length === 0) {
110
+ throw new StatelessCodecError("no_key", purpose);
111
+ }
112
+ let acceptedSlot = null;
113
+ const toSign = Buffer.concat([Buffer.from(purpose), Buffer.from([0]), payloadBuf]);
114
+ for (const { slot, key } of keys) {
115
+ const expected = hmacSha256(key, toSign);
116
+ if (expected.length === macBuf.length && timingSafeEqual(expected, macBuf)) {
117
+ acceptedSlot = slot;
118
+ break;
119
+ }
120
+ }
121
+ if (!acceptedSlot) {
122
+ throw new StatelessCodecError("bad_signature", purpose);
123
+ }
124
+ const payload = parseJson(payloadBuf, purpose);
125
+ checkIat(payload, opts.ttlSeconds, opts.now, purpose);
126
+ return { payload, slot: acceptedSlot };
127
+ }
128
+ /**
129
+ * Mint a sealed (AES-256-GCM) token. The purpose tag becomes the AEAD AAD so
130
+ * that a sealed value minted for one purpose cannot be "opened" as another.
131
+ */
132
+ export function seal(material, purpose, payload) {
133
+ const [{ key }] = deriveSubkeys(material, purpose).filter(k => k.slot === "current");
134
+ const nonce = randomBytes(NONCE_LEN);
135
+ const cipher = createCipheriv(AES_ALG, key, nonce);
136
+ cipher.setAAD(Buffer.from(purpose));
137
+ const payloadBuf = canonicalJson(payload);
138
+ const ciphertext = Buffer.concat([cipher.update(payloadBuf), cipher.final()]);
139
+ const tag = cipher.getAuthTag();
140
+ const blob = Buffer.concat([nonce, ciphertext, tag]);
141
+ return `${STATELESS_VERSION}.${purpose}.${b64urlEncode(blob)}`;
142
+ }
143
+ /**
144
+ * Open a sealed token and return the decoded payload.
145
+ */
146
+ export function open(material, purpose, token, opts) {
147
+ const parts = splitToken(token, purpose);
148
+ if (parts.length !== 3) {
149
+ throw new StatelessCodecError("malformed", purpose);
150
+ }
151
+ const blob = b64urlDecode(parts[2], purpose);
152
+ if (blob.length < NONCE_LEN + TAG_LEN) {
153
+ throw new StatelessCodecError("malformed", purpose);
154
+ }
155
+ const nonce = blob.subarray(0, NONCE_LEN);
156
+ const tag = blob.subarray(blob.length - TAG_LEN);
157
+ const ciphertext = blob.subarray(NONCE_LEN, blob.length - TAG_LEN);
158
+ const keys = deriveSubkeys(material, purpose);
159
+ if (keys.length === 0) {
160
+ throw new StatelessCodecError("no_key", purpose);
161
+ }
162
+ let acceptedSlot = null;
163
+ let plaintext = null;
164
+ for (const { slot, key } of keys) {
165
+ try {
166
+ const decipher = createDecipheriv(AES_ALG, key, nonce);
167
+ decipher.setAAD(Buffer.from(purpose));
168
+ decipher.setAuthTag(tag);
169
+ const p = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
170
+ plaintext = p;
171
+ acceptedSlot = slot;
172
+ break;
173
+ }
174
+ catch {
175
+ // Try the next slot; only throw bad_ciphertext after all slots fail.
176
+ }
177
+ }
178
+ if (!acceptedSlot || !plaintext) {
179
+ throw new StatelessCodecError("bad_ciphertext", purpose);
180
+ }
181
+ const payload = parseJson(plaintext, purpose);
182
+ checkIat(payload, opts.ttlSeconds, opts.now, purpose);
183
+ return { payload, slot: acceptedSlot };
184
+ }
185
+ // ──────────────────────────────────────────────────────────────────────────
186
+ // Shared TTL check
187
+ // ──────────────────────────────────────────────────────────────────────────
188
+ function checkIat(payload, ttlSec, now, purpose) {
189
+ if (typeof payload !== "object" ||
190
+ payload === null ||
191
+ typeof payload.iat !== "number" ||
192
+ typeof payload.v !== "number") {
193
+ throw new StatelessCodecError("bad_schema", purpose);
194
+ }
195
+ const iat = payload.iat;
196
+ const t = nowSec(now);
197
+ if (iat > t + IAT_FUTURE_SKEW_SEC) {
198
+ throw new StatelessCodecError("future_iat", purpose);
199
+ }
200
+ if (ttlSec > 0 && t - iat > ttlSec) {
201
+ throw new StatelessCodecError("expired", purpose);
202
+ }
203
+ }
204
+ // Re-export purposes so callers have one import point.
205
+ export { STATELESS_PURPOSES };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Typed errors for the stateless token codec.
3
+ *
4
+ * Callers should translate these to appropriate OAuth / HTTP errors in their
5
+ * local context. The codec itself never logs token values; it returns errors
6
+ * that carry only the reason and the purpose tag.
7
+ */
8
+ export class StatelessCodecError extends Error {
9
+ reason;
10
+ purpose;
11
+ constructor(reason, purpose, message) {
12
+ super(message ?? `stateless codec error: ${reason} (purpose=${purpose})`);
13
+ this.name = "StatelessCodecError";
14
+ this.reason = reason;
15
+ this.purpose = purpose;
16
+ }
17
+ }
18
+ /** Thrown at module init when the configuration is missing or malformed. */
19
+ export class StatelessConfigError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "StatelessConfigError";
23
+ }
24
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public surface of the stateless token codec.
3
+ *
4
+ * Higher-level callers (oauth-proxy, streamable HTTP) should import from this
5
+ * module only, so future refactors inside `stateless/` remain non-breaking.
6
+ */
7
+ export { StatelessCodecError, StatelessConfigError } from "./errors.js";
8
+ export { STATELESS_PURPOSES, STATELESS_VERSION, } from "./types.js";
9
+ export { decodeSecret, deriveSubkey, deriveSubkeys, hmacSha256, loadKeyMaterialFromEnv, } from "./secret.js";
10
+ export { open, seal, sign, verify, } from "./codec.js";
11
+ export { looksLikeStatelessClientId, mintClientId, openClientId, } from "./client-id.js";
12
+ export { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./pending-auth.js";
13
+ export { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stored-tokens.js";
14
+ export { looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./session-id.js";