@zereight/mcp-gitlab 2.1.4 → 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.
- package/README.ko.md +442 -0
- package/README.md +12 -0
- package/README.zh-CN.md +442 -0
- package/build/config.js +65 -2
- package/build/index.js +348 -5
- package/build/oauth-proxy.js +182 -47
- package/build/stateless/client-id.js +68 -0
- package/build/stateless/codec.js +205 -0
- package/build/stateless/errors.js +24 -0
- package/build/stateless/index.js +14 -0
- package/build/stateless/pending-auth.js +65 -0
- package/build/stateless/secret.js +98 -0
- package/build/stateless/session-id.js +68 -0
- package/build/stateless/stored-tokens.js +66 -0
- package/build/stateless/types.js +18 -0
- package/build/test/schema-tests.js +81 -3
- package/build/test/stateless/callback-proxy.test.js +393 -0
- package/build/test/stateless/client-id.test.js +176 -0
- package/build/test/stateless/codec.test.js +328 -0
- package/build/test/stateless/config-ttl.test.js +149 -0
- package/build/test/stateless/session-id-integration.test.js +675 -0
- package/build/test/stateless/session-id.test.js +131 -0
- package/build/test/test-json-schema.js +148 -0
- package/build/utils/schema.js +40 -6
- package/package.json +4 -3
package/build/oauth-proxy.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
224
|
+
redirect_uris: redirectUris,
|
|
166
225
|
token_endpoint_auth_method: "none",
|
|
167
|
-
grant_types:
|
|
168
|
-
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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";
|