@zereight/mcp-gitlab 2.1.5 → 2.1.7
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 +43 -26
- package/README.zh-CN.md +442 -0
- package/build/config.js +65 -2
- package/build/index.js +477 -6
- package/build/oauth-proxy.js +182 -47
- package/build/schemas.js +78 -0
- 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/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-tags.js +206 -0
- package/build/test/test-toolset-filtering.js +12 -1
- package/build/tools/registry.js +41 -1
- package/package.json +3 -2
|
@@ -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";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S2 — sealed OAuth `state` for callback-proxy mode.
|
|
3
|
+
*
|
|
4
|
+
* `authorize()` normally stores a PendingAuthTransaction in an in-memory
|
|
5
|
+
* BoundedLRUMap keyed by a random `state` parameter. The `/callback` handler
|
|
6
|
+
* looks it up when GitLab redirects the user back. Under HPA this breaks if
|
|
7
|
+
* /authorize lands on pod A and /callback on pod B.
|
|
8
|
+
*
|
|
9
|
+
* Stateless mode replaces the random `state` with a sealed token that itself
|
|
10
|
+
* carries the PendingAuthTransaction. No server-side storage; any pod that
|
|
11
|
+
* holds the shared secret can open it.
|
|
12
|
+
*
|
|
13
|
+
* Uses AEAD (AES-256-GCM) because the payload contains `proxyCodeVerifier`,
|
|
14
|
+
* which is a PKCE secret until the token exchange completes.
|
|
15
|
+
*/
|
|
16
|
+
import { open, seal } from "./codec.js";
|
|
17
|
+
import { StatelessCodecError } from "./errors.js";
|
|
18
|
+
import { STATELESS_PURPOSES, } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Mint a sealed state parameter. The returned string is safe to place in a
|
|
21
|
+
* URL query string; it uses only base64url characters plus dots.
|
|
22
|
+
*/
|
|
23
|
+
export function mintPendingAuthState(material, input) {
|
|
24
|
+
const iat = input.now ? input.now() : Math.floor(Date.now() / 1000);
|
|
25
|
+
const payload = {
|
|
26
|
+
v: 1,
|
|
27
|
+
iat,
|
|
28
|
+
cid: input.clientId,
|
|
29
|
+
cru: input.clientRedirectUri,
|
|
30
|
+
ccc: input.clientCodeChallenge,
|
|
31
|
+
pcv: input.proxyCodeVerifier,
|
|
32
|
+
};
|
|
33
|
+
if (input.clientState !== undefined)
|
|
34
|
+
payload.cs = input.clientState;
|
|
35
|
+
return seal(material, STATELESS_PURPOSES.PENDING_AUTH, payload);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Open a sealed state parameter. Returns null on any failure.
|
|
39
|
+
*
|
|
40
|
+
* NOTE on replay: stateless mode cannot enforce one-time-use without a shared
|
|
41
|
+
* store. Replaying a `state` value is useless on its own because GitLab's
|
|
42
|
+
* authorization code is single-use; an attacker who replays just `state`
|
|
43
|
+
* without a matching unredeemed GitLab code cannot extract tokens.
|
|
44
|
+
*/
|
|
45
|
+
export function openPendingAuthState(material, state, ttlSeconds, now) {
|
|
46
|
+
try {
|
|
47
|
+
const { payload } = open(material, STATELESS_PURPOSES.PENDING_AUTH, state, { ttlSeconds, now });
|
|
48
|
+
if (typeof payload.cid !== "string" ||
|
|
49
|
+
typeof payload.cru !== "string" ||
|
|
50
|
+
typeof payload.ccc !== "string" ||
|
|
51
|
+
typeof payload.pcv !== "string") {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return payload;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof StatelessCodecError)
|
|
58
|
+
return null;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Heuristic for distinguishing stateless state from legacy UUIDs. */
|
|
63
|
+
export function looksLikeStatelessState(value) {
|
|
64
|
+
return value.startsWith("v1.ps.");
|
|
65
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret / keyring loader for stateless mode.
|
|
3
|
+
*
|
|
4
|
+
* Loads the master secret(s) from env vars and derives per-purpose subkeys via
|
|
5
|
+
* HKDF-SHA256. The caller (the codec) supplies a purpose tag when computing
|
|
6
|
+
* or verifying a token; that tag is used both as HKDF info and as AEAD
|
|
7
|
+
* associated data, which gives us domain separation by construction.
|
|
8
|
+
*/
|
|
9
|
+
import { createHmac, hkdfSync } from "node:crypto";
|
|
10
|
+
import { StatelessConfigError } from "./errors.js";
|
|
11
|
+
const MIN_SECRET_BYTES = 32;
|
|
12
|
+
const HKDF_SALT = Buffer.from("gitlab-mcp-stateless-v1");
|
|
13
|
+
const HKDF_HASH = "sha256";
|
|
14
|
+
const KEY_BYTES = 32;
|
|
15
|
+
/**
|
|
16
|
+
* Decode a base64url-encoded secret string into a Buffer of at least
|
|
17
|
+
* MIN_SECRET_BYTES. Also accepts standard base64 for operator convenience.
|
|
18
|
+
*/
|
|
19
|
+
export function decodeSecret(value, label) {
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (!trimmed) {
|
|
22
|
+
throw new StatelessConfigError(`${label} is empty`);
|
|
23
|
+
}
|
|
24
|
+
let buf;
|
|
25
|
+
try {
|
|
26
|
+
// Node's base64url decoder is forgiving of padding and accepts base64 too,
|
|
27
|
+
// but we still validate the length explicitly below.
|
|
28
|
+
buf = Buffer.from(trimmed, "base64url");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new StatelessConfigError(`${label} is not valid base64url`);
|
|
32
|
+
}
|
|
33
|
+
if (buf.length < MIN_SECRET_BYTES) {
|
|
34
|
+
throw new StatelessConfigError(`${label} must decode to at least ${MIN_SECRET_BYTES} bytes (got ${buf.length})`);
|
|
35
|
+
}
|
|
36
|
+
return buf;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Load the keyring from the environment. Returns null when stateless mode is
|
|
40
|
+
* disabled. Throws StatelessConfigError when enabled but misconfigured.
|
|
41
|
+
*
|
|
42
|
+
* `enabled` is an explicit input so the caller can drive enablement from the
|
|
43
|
+
* already-resolved config flag (which honors both env var and CLI flag
|
|
44
|
+
* precedence), rather than re-reading raw env state here. Re-parsing
|
|
45
|
+
* `OAUTH_STATELESS_MODE` from `env` would silently ignore the CLI flag
|
|
46
|
+
* `--oauth-stateless-mode`, leaving `STATELESS_MATERIAL` null and falling back
|
|
47
|
+
* to per-pod state in multi-pod deployments.
|
|
48
|
+
*/
|
|
49
|
+
export function loadKeyMaterialFromEnv(enabled, env = process.env) {
|
|
50
|
+
if (!enabled)
|
|
51
|
+
return null;
|
|
52
|
+
const current = env.OAUTH_STATELESS_SECRET;
|
|
53
|
+
if (!current) {
|
|
54
|
+
throw new StatelessConfigError("OAUTH_STATELESS_MODE=true requires OAUTH_STATELESS_SECRET (base64url, ≥32 bytes)");
|
|
55
|
+
}
|
|
56
|
+
const material = {
|
|
57
|
+
current: decodeSecret(current, "OAUTH_STATELESS_SECRET"),
|
|
58
|
+
};
|
|
59
|
+
const previous = env.OAUTH_STATELESS_SECRET_PREVIOUS;
|
|
60
|
+
if (previous) {
|
|
61
|
+
material.previous = decodeSecret(previous, "OAUTH_STATELESS_SECRET_PREVIOUS");
|
|
62
|
+
}
|
|
63
|
+
return material;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Derive a 32-byte subkey for a given purpose and slot.
|
|
67
|
+
*/
|
|
68
|
+
export function deriveSubkey(material, slot, purpose) {
|
|
69
|
+
const master = slot === "current" ? material.current : material.previous;
|
|
70
|
+
if (!master) {
|
|
71
|
+
// Callers are expected to check available() before asking for previous.
|
|
72
|
+
throw new StatelessConfigError(`no ${slot} secret configured`);
|
|
73
|
+
}
|
|
74
|
+
const info = Buffer.from(`gitlab-mcp/${purpose}`);
|
|
75
|
+
const derived = hkdfSync(HKDF_HASH, master, HKDF_SALT, info, KEY_BYTES);
|
|
76
|
+
// hkdfSync returns ArrayBuffer; wrap for Buffer ergonomics.
|
|
77
|
+
return Buffer.from(derived);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Convenience wrapper that returns both available slots' subkeys for a
|
|
81
|
+
* purpose, in order: current first, then previous (if configured).
|
|
82
|
+
*/
|
|
83
|
+
export function deriveSubkeys(material, purpose) {
|
|
84
|
+
const out = [
|
|
85
|
+
{ slot: "current", key: deriveSubkey(material, "current", purpose) },
|
|
86
|
+
];
|
|
87
|
+
if (material.previous) {
|
|
88
|
+
out.push({ slot: "previous", key: deriveSubkey(material, "previous", purpose) });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Constant-time HMAC-SHA256. Exposed so the codec module can avoid reimporting
|
|
94
|
+
* createHmac and so rotation-aware verification uses a single helper.
|
|
95
|
+
*/
|
|
96
|
+
export function hmacSha256(key, data) {
|
|
97
|
+
return createHmac("sha256", key).update(data).digest();
|
|
98
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S4 — sealed `Mcp-Session-Id` for the Streamable HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* The MCP Streamable HTTP transport identifies sessions by the
|
|
5
|
+
* `Mcp-Session-Id` header, which the server mints on the initialization
|
|
6
|
+
* response and the client echoes back on every subsequent request.
|
|
7
|
+
*
|
|
8
|
+
* Today's code stores `{header, token, apiUrl, lastUsed}` in a
|
|
9
|
+
* `Record<sid, AuthData>` on the pod that created the session, which breaks
|
|
10
|
+
* when a subsequent request is routed to a different pod.
|
|
11
|
+
*
|
|
12
|
+
* Stateless mode replaces the server-minted UUID with an AEAD-sealed value
|
|
13
|
+
* that carries the auth data directly. Any pod sharing
|
|
14
|
+
* OAUTH_STATELESS_SECRET can open the sid and rebuild the auth context
|
|
15
|
+
* on the fly, without consulting any shared store.
|
|
16
|
+
*
|
|
17
|
+
* Uses AEAD (AES-256-GCM) because the payload contains the bearer token /
|
|
18
|
+
* PAT / job token. Because the sid is presented on every request, an
|
|
19
|
+
* attacker who captures one session_id effectively captures the bearer
|
|
20
|
+
* token behind it — the same security boundary as a stolen Authorization
|
|
21
|
+
* header. TLS is mandatory; log redaction is recommended (phase 6).
|
|
22
|
+
*/
|
|
23
|
+
import { open, seal } from "./codec.js";
|
|
24
|
+
import { StatelessCodecError } from "./errors.js";
|
|
25
|
+
import { STATELESS_PURPOSES, } from "./types.js";
|
|
26
|
+
/**
|
|
27
|
+
* Mint a sealed session id. The resulting string is returned via the
|
|
28
|
+
* `Mcp-Session-Id` response header on the initialization response.
|
|
29
|
+
*/
|
|
30
|
+
export function mintSessionId(material, input) {
|
|
31
|
+
const iat = input.now ? input.now() : Math.floor(Date.now() / 1000);
|
|
32
|
+
const payload = {
|
|
33
|
+
v: 1,
|
|
34
|
+
iat,
|
|
35
|
+
h: input.header,
|
|
36
|
+
t: input.token,
|
|
37
|
+
u: input.apiUrl,
|
|
38
|
+
};
|
|
39
|
+
return seal(material, STATELESS_PURPOSES.SESSION_ID, payload);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Open a sealed session id and return the embedded auth context.
|
|
43
|
+
*
|
|
44
|
+
* Returns null on any failure (malformed, tampered, expired, wrong secret).
|
|
45
|
+
*/
|
|
46
|
+
export function openSessionId(material, sid, ttlSeconds, now) {
|
|
47
|
+
try {
|
|
48
|
+
const { payload } = open(material, STATELESS_PURPOSES.SESSION_ID, sid, { ttlSeconds, now });
|
|
49
|
+
if ((payload.h !== "Authorization" &&
|
|
50
|
+
payload.h !== "Private-Token" &&
|
|
51
|
+
payload.h !== "JOB-TOKEN") ||
|
|
52
|
+
typeof payload.t !== "string" ||
|
|
53
|
+
payload.t.length === 0 ||
|
|
54
|
+
typeof payload.u !== "string") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (err instanceof StatelessCodecError)
|
|
61
|
+
return null;
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Heuristic check: does the header value look like a stateless sid? */
|
|
66
|
+
export function looksLikeStatelessSessionId(value) {
|
|
67
|
+
return value.startsWith("v1.sid.");
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 — sealed proxy authorization code for callback-proxy mode.
|
|
3
|
+
*
|
|
4
|
+
* After `/callback` exchanges the GitLab auth code for tokens, the SDK-style
|
|
5
|
+
* flow requires the MCP server to return a fresh auth code to the MCP client
|
|
6
|
+
* via redirect, and to accept that code on the subsequent POST /token.
|
|
7
|
+
*
|
|
8
|
+
* The legacy implementation stored the GitLab tokens in a per-pod
|
|
9
|
+
* BoundedLRUMap keyed by a random UUID proxy code. Under HPA this breaks when
|
|
10
|
+
* /callback and /token land on different pods.
|
|
11
|
+
*
|
|
12
|
+
* Stateless mode replaces the random UUID proxy code with an AEAD-sealed
|
|
13
|
+
* token that carries the stored tokens and the client PKCE binding directly.
|
|
14
|
+
*
|
|
15
|
+
* Uses AEAD (AES-256-GCM) because the payload contains the GitLab access
|
|
16
|
+
* token and refresh token.
|
|
17
|
+
*
|
|
18
|
+
* Replay: proxyCode replay is defeated by two mitigations:
|
|
19
|
+
* - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default 600s)
|
|
20
|
+
* - PKCE binding (the client must still present a matching code_verifier)
|
|
21
|
+
*/
|
|
22
|
+
import { open, seal } from "./codec.js";
|
|
23
|
+
import { StatelessCodecError } from "./errors.js";
|
|
24
|
+
import { STATELESS_PURPOSES, } from "./types.js";
|
|
25
|
+
/**
|
|
26
|
+
* Mint a sealed proxy authorization code. The resulting string is used as the
|
|
27
|
+
* `code` query parameter in the redirect to the MCP client's redirect_uri.
|
|
28
|
+
*/
|
|
29
|
+
export function mintStoredTokensCode(material, input) {
|
|
30
|
+
const iat = input.now ? input.now() : Math.floor(Date.now() / 1000);
|
|
31
|
+
const payload = {
|
|
32
|
+
v: 1,
|
|
33
|
+
iat,
|
|
34
|
+
t: input.tokens,
|
|
35
|
+
cid: input.clientId,
|
|
36
|
+
cru: input.clientRedirectUri,
|
|
37
|
+
ccc: input.clientCodeChallenge,
|
|
38
|
+
};
|
|
39
|
+
return seal(material, STATELESS_PURPOSES.STORED_TOKENS, payload);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Open a sealed proxy authorization code. Returns null on any failure.
|
|
43
|
+
*/
|
|
44
|
+
export function openStoredTokensCode(material, code, ttlSeconds, now) {
|
|
45
|
+
try {
|
|
46
|
+
const { payload } = open(material, STATELESS_PURPOSES.STORED_TOKENS, code, { ttlSeconds, now });
|
|
47
|
+
if (typeof payload.cid !== "string" ||
|
|
48
|
+
typeof payload.cru !== "string" ||
|
|
49
|
+
typeof payload.ccc !== "string" ||
|
|
50
|
+
typeof payload.t !== "object" ||
|
|
51
|
+
payload.t === null ||
|
|
52
|
+
typeof payload.t.access_token !== "string") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err instanceof StatelessCodecError)
|
|
59
|
+
return null;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Heuristic for distinguishing stateless proxy codes from legacy UUIDs. */
|
|
64
|
+
export function looksLikeStatelessStoredTokensCode(value) {
|
|
65
|
+
return value.startsWith("v1.pc.");
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the stateless token codec.
|
|
3
|
+
*
|
|
4
|
+
* Purposes are string constants, each mapping one-to-one to a derived subkey.
|
|
5
|
+
* They appear in the wire format and in HKDF info to guarantee that a token
|
|
6
|
+
* minted for one purpose cannot be successfully verified as another.
|
|
7
|
+
*/
|
|
8
|
+
export const STATELESS_VERSION = "v1";
|
|
9
|
+
export const STATELESS_PURPOSES = {
|
|
10
|
+
/** Signed client_id issued at DCR. */
|
|
11
|
+
CLIENT_ID: "cid",
|
|
12
|
+
/** Sealed OAuth state, carried through the GitLab consent screen. */
|
|
13
|
+
PENDING_AUTH: "ps",
|
|
14
|
+
/** Sealed proxy authorization code, returned to the MCP client. */
|
|
15
|
+
STORED_TOKENS: "pc",
|
|
16
|
+
/** Sealed Mcp-Session-Id, carries session auth across pod hops. */
|
|
17
|
+
SESSION_ID: "sid",
|
|
18
|
+
};
|