enigma-memory 0.1.18 → 0.1.22
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.md +76 -24
- package/apps/cli/bin/enigma-desktop.mjs +140 -0
- package/apps/cli/bin/enigma-terminal.mjs +78 -0
- package/apps/cli/bin/enigma.mjs +1923 -285
- package/apps/desktop/electron-main.cjs +217 -0
- package/apps/desktop/package.json +12 -0
- package/apps/desktop/src/app.js +264 -7
- package/apps/desktop/src/index.html +3514 -1373
- package/apps/desktop/src/launch-electron.mjs +51 -0
- package/apps/desktop/src/server.mjs +2914 -0
- package/apps/desktop/src/styles.css +2972 -260
- package/apps/desktop/src/zk-browser-prove.mjs +53 -0
- package/apps/desktop/src/zk-state.mjs +1789 -0
- package/apps/gateway/bin/enigma-gateway.mjs +102 -5
- package/apps/gateway/src/server.mjs +271 -8
- package/apps/ios/EnigmaCore/Package.swift +12 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/EnigmaAPIClient.swift +227 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/Models.swift +278 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/PKCE.swift +96 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/PrivacyMinimizer.swift +187 -0
- package/apps/ios/EnigmaCore/Sources/EnigmaCore/ToolModels.swift +129 -0
- package/apps/ios/EnigmaCore/Tests/EnigmaCoreTests/EnigmaCoreTests.swift +42 -0
- package/apps/ios/EnigmaIOS/Enigma/AppModel.swift +346 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AccentColor.colorset/Contents.json +12 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AppIcon.appiconset/Contents.json +11 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/AppIcon.appiconset/EnigmaAppIcon.png +0 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/Contents.json +3 -0
- package/apps/ios/EnigmaIOS/Enigma/Assets.xcassets/LaunchBackground.colorset/Contents.json +12 -0
- package/apps/ios/EnigmaIOS/Enigma/ChatView.swift +181 -0
- package/apps/ios/EnigmaIOS/Enigma/CouncilView.swift +78 -0
- package/apps/ios/EnigmaIOS/Enigma/CreateView.swift +152 -0
- package/apps/ios/EnigmaIOS/Enigma/EnigmaApp.swift +52 -0
- package/apps/ios/EnigmaIOS/Enigma/Info.plist +52 -0
- package/apps/ios/EnigmaIOS/Enigma/NaturalLanguagePrivacyTagger.swift +26 -0
- package/apps/ios/EnigmaIOS/Enigma/OAuthClient.swift +321 -0
- package/apps/ios/EnigmaIOS/Enigma/OnboardingView.swift +105 -0
- package/apps/ios/EnigmaIOS/Enigma/PrivateVaultView.swift +275 -0
- package/apps/ios/EnigmaIOS/Enigma/SecureStore.swift +76 -0
- package/apps/ios/EnigmaIOS/Enigma/SettingsView.swift +60 -0
- package/apps/ios/EnigmaIOS/Enigma/Theme.swift +80 -0
- package/apps/ios/EnigmaIOS/EnigmaIOS.xcodeproj/project.pbxproj +211 -0
- package/apps/ios/EnigmaIOS/EnigmaIOS.xcodeproj/xcshareddata/xcschemes/Enigma.xcscheme +23 -0
- package/apps/native-host/README.md +19 -8
- package/apps/native-host/bin/enigma-native-host.mjs +229 -13
- package/apps/relay/bin/enigma-relay.mjs +103 -5
- package/apps/relay/src/federation-runtime.mjs +618 -0
- package/apps/relay/src/server.mjs +310 -9
- package/apps/verifier/bin/enigma-verify.mjs +327 -11
- package/cortex-v3/circuits/build/intent_vk_bytes.json +35 -0
- package/cortex-v3/circuits/build/sale_vk_bytes.json +35 -0
- package/cortex-v3/circuits/build/vk_bytes.json +32 -0
- package/cortex-v3/proving-assets.json +64 -0
- package/cortex-v3/zk/BUILD-CONTRACT.md +87 -0
- package/cortex-v3/zk/action-transition-vk.json +119 -0
- package/cortex-v3/zk/alias-adversarial.test.mjs +220 -0
- package/cortex-v3/zk/groth16-verify-child.mjs +17 -0
- package/cortex-v3/zk/intent-witness.mjs +365 -0
- package/cortex-v3/zk/intent-witness.test.mjs +485 -0
- package/cortex-v3/zk/proving-assets.mjs +203 -0
- package/cortex-v3/zk/sale-witness.mjs +783 -0
- package/cortex-v3/zk/sale-witness.test.mjs +784 -0
- package/cortex-v3/zk/sealed-sale-release-vk.json +119 -0
- package/cortex-v3/zk/settlement-evidence.mjs +722 -0
- package/cortex-v3/zk/setup-intent.mjs +688 -0
- package/cortex-v3/zk/setup-sale.mjs +666 -0
- package/cortex-v3/zk/setup.mjs +594 -0
- package/cortex-v3/zk/witness.mjs +184 -0
- package/cortex-v3/zk/zk-codec.mjs +232 -0
- package/cortex-v3/zk/zk-codec.test.mjs +293 -0
- package/cortex-v3/zk/zk-settle.mjs +370 -0
- package/cortex-v3/zk/zk-tree.mjs +256 -0
- package/cortex-v3/zk/zk-tree.test.mjs +419 -0
- package/deploy/docker-compose.local-production-simulation.yml +36 -0
- package/docs/browser-extension-install.md +8 -6
- package/docs/client-connectors.md +15 -11
- package/docs/developer-ecosystem.md +15 -13
- package/docs/enigma-memory-ready-conformance.md +11 -9
- package/docs/install-anywhere.md +61 -28
- package/docs/installers-and-desktop.md +8 -7
- package/docs/novelty-invention-candidates.md +161 -161
- package/docs/proof-network-claim-boundaries.md +320 -318
- package/examples/01-quickstart-agent/index.mjs +49 -0
- package/examples/01_agent_memory_quickstart.mjs +57 -0
- package/examples/02-multi-agent-swarm/index.mjs +57 -0
- package/examples/02_cross_model_passport.mjs +64 -0
- package/examples/03-langchain-memory/index.mjs +41 -0
- package/examples/03_poseidon_commitment_verification.mjs +71 -0
- package/examples/04-python-trading-agent/trader.py +49 -0
- package/examples/README.md +27 -0
- package/examples/ci/github-actions.yml +7 -2
- package/package.json +142 -11
- package/packages/adapters/PACKAGE_CONTRACT.md +1 -1
- package/packages/connectors/src/index.js +196 -4
- package/packages/connectors/swarm-router.mjs +168 -0
- package/packages/core/src/index.js +248 -1
- package/packages/core/src/version.mjs +7 -0
- package/packages/dev-tools/package.json +19 -0
- package/packages/dev-tools/src/index.js +4 -0
- package/packages/dev-tools/src/memory-benchmark-suite.js +112 -0
- package/packages/dev-tools/src/swarm-simulator.js +101 -0
- package/packages/dev-tools/src/vault-inspector.js +114 -0
- package/packages/dev-tools/src/vector-benchmark.js +100 -0
- package/packages/developer-platform/src/access-credentials.js +341 -0
- package/packages/developer-platform/src/http.js +132 -0
- package/packages/developer-platform/src/index.js +4 -0
- package/packages/developer-platform/src/usage-http.js +60 -0
- package/packages/developer-platform/src/usage.js +295 -0
- package/packages/enclave-runtime/attestation.mjs +159 -0
- package/packages/enclave-runtime/index.mjs +47 -0
- package/packages/enclave-runtime/session-manager.mjs +253 -0
- package/packages/enclave-runtime/zeroization-proof.mjs +227 -0
- package/packages/enigma-reflex/package.json +14 -0
- package/packages/enigma-reflex/src/index.js +204 -0
- package/packages/enigma-reflex/training/generate-dataset.mjs +40 -0
- package/packages/enigma-reflex/training/requirements.txt +8 -0
- package/packages/enigma-reflex/training/train.py +314 -0
- package/packages/enigma-weave/LICENSE +22 -0
- package/packages/enigma-weave/UPSTREAM.json +21 -0
- package/packages/enigma-weave/package.json +14 -0
- package/packages/enigma-weave/src/index.js +286 -0
- package/packages/hosted-cloud/src/index.js +80 -5
- package/packages/importers/src/index.js +432 -0
- package/packages/inference-runtime/src/browser.js +401 -0
- package/packages/inference-runtime/src/chat.js +265 -0
- package/packages/inference-runtime/src/code.js +407 -0
- package/packages/inference-runtime/src/contracts.js +162 -0
- package/packages/inference-runtime/src/http.js +232 -0
- package/packages/inference-runtime/src/image.js +186 -0
- package/packages/inference-runtime/src/index.js +10 -0
- package/packages/inference-runtime/src/model-router.js +320 -0
- package/packages/inference-runtime/src/platform.js +125 -0
- package/packages/inference-runtime/src/privacy.js +400 -0
- package/packages/inference-runtime/src/video.js +253 -0
- package/packages/mcp-server/README.md +22 -6
- package/packages/mcp-server/bin/enigma-mcp.mjs +2 -1
- package/packages/mcp-server/src/index.js +1418 -105
- package/packages/mcp-server/src/oauth.js +561 -0
- package/packages/mcp-server/src/private-handoff.js +84 -0
- package/packages/mcp-server/src/remote-http.js +273 -0
- package/packages/mcp-server/src/remote-policy.js +72 -0
- package/packages/mcp-server/swarm-bridge.mjs +361 -0
- package/packages/mesh/index.d.ts +283 -0
- package/packages/mesh/package.json +23 -0
- package/packages/mesh/src/crypto.js +189 -0
- package/packages/mesh/src/federation-packets.js +353 -0
- package/packages/mesh/src/gossip.js +311 -0
- package/packages/mesh/src/index.js +6 -0
- package/packages/mesh/src/protocol.js +255 -0
- package/packages/mesh/src/router.js +279 -0
- package/packages/mesh/src/transport.js +306 -0
- package/packages/passport/src/index.js +426 -1
- package/packages/private-economy/src/credits-http.js +100 -0
- package/packages/private-economy/src/credits.js +447 -0
- package/packages/private-economy/src/index.js +5 -0
- package/packages/private-economy/src/payments-http.js +120 -0
- package/packages/private-economy/src/payments.js +509 -0
- package/packages/private-economy/src/x402.js +346 -0
- package/packages/proof-network/PACKAGE_CONTRACT.md +21 -0
- package/packages/rag/index.d.ts +182 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/THIRD_PARTY_LICENSES.txt +207 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/config.json +25 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx +0 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/sha256-manifest.json +28 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/tokenizer.json +30686 -0
- package/packages/rag/models/Xenova/all-MiniLM-L6-v2/tokenizer_config.json +15 -0
- package/packages/rag/package.json +27 -0
- package/packages/rag/src/blinded-search.js +109 -0
- package/packages/rag/src/bm25.js +169 -0
- package/packages/rag/src/embeddings.js +459 -0
- package/packages/rag/src/hybrid.js +76 -0
- package/packages/rag/src/index.js +38 -0
- package/packages/rag/src/reranker.js +61 -0
- package/packages/rag/src/research.js +107 -0
- package/packages/rag/src/vector-store.js +430 -0
- package/packages/rag/src/verify-model-artifacts.mjs +4 -0
- package/packages/sdk/index.d.ts +760 -0
- package/packages/sdk/package.json +33 -0
- package/packages/sdk/python/README.md +24 -0
- package/packages/sdk/python/enigma_sdk.py +250 -0
- package/packages/sdk/python/pyproject.toml +34 -0
- package/packages/sdk/python/requirements.txt +1 -0
- package/packages/sdk/python/setup.py +20 -0
- package/packages/sdk/src/federation/capability-grant.js +389 -0
- package/packages/sdk/src/federation/federation-router.js +360 -0
- package/packages/sdk/src/federation/ghostmesh-bridge.js +497 -0
- package/packages/sdk/src/federation/index.js +3 -0
- package/packages/sdk/src/index.js +1796 -0
- package/packages/sdk/src/intelligence/contradiction.js +337 -0
- package/packages/sdk/src/intelligence/decision-engine.js +155 -0
- package/packages/sdk/src/intelligence/index.js +4 -0
- package/packages/sdk/src/intelligence/ontology.js +122 -0
- package/packages/sdk/src/intelligence/temporal.js +123 -0
- package/packages/sdk/src/market-client.js +142 -0
- package/packages/sdk/src/mesh-client.js +110 -0
- package/packages/sdk/src/middleware/index.js +3 -0
- package/packages/sdk/src/middleware/langchain.js +159 -0
- package/packages/sdk/src/middleware/llamaindex.js +101 -0
- package/packages/sdk/src/middleware/vercel-ai.js +112 -0
- package/packages/sdk/src/rag-client.js +85 -0
- package/packages/sdk/src/swarm-orchestrator.js +260 -0
- package/packages/settlement/PACKAGE_CONTRACT.md +1 -1
- package/packages/snapcompact/THIRD_PARTY_LICENSES.txt +40 -0
- package/packages/snapcompact/assets/8x13-latin1.bdf +3837 -0
- package/packages/snapcompact/index.d.ts +284 -0
- package/packages/snapcompact/package.json +25 -0
- package/packages/snapcompact/src/index.js +716 -0
- package/packages/storage/PACKAGE_CONTRACT.md +1 -1
- package/packages/terminal-console/animations.mjs +240 -0
- package/packages/terminal-console/auto-anchor.mjs +220 -0
- package/packages/terminal-console/banner.mjs +91 -0
- package/packages/terminal-console/commands.mjs +459 -0
- package/packages/terminal-console/delegation.mjs +152 -0
- package/packages/terminal-console/index.mjs +5 -0
- package/packages/terminal-console/outbox.mjs +143 -0
- package/packages/terminal-console/phantom-bridge.mjs +637 -0
- package/packages/terminal-console/repl.mjs +136 -0
- package/packages/terminal-console/signer-store.mjs +130 -0
- package/packages/terminal-console/solana-rpc.mjs +214 -0
- package/packages/terminal-console/solana-transport.mjs +189 -0
- package/packages/terminal-tui/dashboard.mjs +214 -0
- package/packages/terminal-tui/index.mjs +28 -0
- package/packages/terminal-tui/merkle-tree-renderer.mjs +268 -0
- package/packages/terminal-tui/telemetry-hud.mjs +137 -0
- package/packages/vault/index.d.ts +449 -0
- package/packages/vault/package.json +27 -0
- package/packages/vault/src/e2ee.mjs +393 -0
- package/packages/vault/src/enclave.js +481 -0
- package/packages/vault/src/erasure.js +207 -0
- package/packages/vault/src/index.js +1018 -155
- package/packages/vault/src/persistence.js +307 -0
- package/packages/vault/src/poseidon.js +354 -0
- package/packages/vault/src/receipt.js +459 -0
- package/scripts/benchmark-optical-context.mjs +166 -0
- package/scripts/bootstrap-enigma.mjs +502 -0
- package/scripts/build-edge-backend-workers.mjs +20 -5
- package/scripts/build-goal-completion-audit.mjs +72 -25
- package/scripts/build-hosted-api-key-lifecycle.mjs +26 -8
- package/scripts/build-hosted-customer-lifecycle.mjs +20 -3
- package/scripts/build-hosted-probe-worker.mjs +19 -4
- package/scripts/build-installer-assets.mjs +41 -21
- package/scripts/build-operator-evidence-starter.mjs +59 -1
- package/scripts/build-production-backend-env-kit.mjs +2 -0
- package/scripts/build-production-unblocker.mjs +3 -0
- package/scripts/check.mjs +17 -3
- package/scripts/collect-hosted-backend-live-evidence.mjs +49 -12
- package/scripts/install-enigma-local.mjs +18 -5
- package/scripts/release-audit.mjs +65 -109
- package/scripts/release-provenance.mjs +6 -0
- package/scripts/run-backend-readiness-smoke.mjs +112 -10
- package/scripts/scan-secrets.mjs +1 -0
- package/scripts/simulate-production-env.mjs +7 -2
- package/scripts/validate-hosted-backend-live.mjs +112 -1
- package/specs/antibody-pack-v1.schema.json +95 -0
- package/specs/antigen-envelope-v1.schema.json +81 -0
- package/specs/boundary-manifest-v1.schema.json +35 -35
- package/specs/capsule-v1.schema.json +55 -55
- package/specs/claim-boundary-manifest-v1.schema.json +22 -22
- package/specs/claim-ledger-v1.schema.json +291 -0
- package/specs/context-passport-v1.schema.json +59 -0
- package/specs/deletion-tombstone-v1.schema.json +26 -26
- package/specs/evidence-packet-v1.schema.json +177 -0
- package/specs/hosted-backend-live-evidence-v1.schema.json +72 -3
- package/specs/immune-scan-report-v1.schema.json +112 -0
- package/specs/lifecycle-receipt-log-v1.schema.json +67 -0
- package/specs/memory-atom-v1.schema.json +59 -0
- package/specs/memory-event-v1.schema.json +42 -42
- package/specs/passport-v1.schema.json +50 -50
- package/specs/proof-of-non-use-v1.schema.json +65 -0
- package/specs/quarantine-record-v1.schema.json +126 -0
- package/specs/receipt-v1.schema.json +61 -61
- package/specs/state-checkpoint-v1.schema.json +37 -37
- package/specs/trust-bundle-v1.schema.json +56 -56
- package/specs/trust-card-v1.schema.json +119 -0
- package/docs/proof-network-launch-plan.md +0 -421
- package/packages/metering/PACKAGE_CONTRACT.md +0 -20
- package/scripts/build-ai-orchestration-plan.mjs +0 -248
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const STORE_SCHEMA = 'enigma.oauth_store.v1';
|
|
7
|
+
const DEFAULT_SCOPES = Object.freeze(['mcp:invoke']);
|
|
8
|
+
const DEFAULT_NATIVE_REDIRECT_SCHEMES = Object.freeze(['enigma-ios']);
|
|
9
|
+
export const ENIGMA_OAUTH_SUPPORTED_SCOPES = Object.freeze([
|
|
10
|
+
'mcp:invoke', 'models:invoke', 'images:generate', 'video:generate', 'code:execute', 'browser:control', 'usage:read', 'credits:create', 'offline_access',
|
|
11
|
+
'memory:read', 'memory:write', 'memory:delete', 'vault:admin',
|
|
12
|
+
'swarm:read', 'swarm:write', 'security:inspect', 'metering:write',
|
|
13
|
+
'settlement:read', 'settlement:write', 'settlement:operator', 'nullifier:prepare', 'payments:execute',
|
|
14
|
+
]);
|
|
15
|
+
const TOKEN_BYTES = 32;
|
|
16
|
+
|
|
17
|
+
class OAuthHttpError extends Error {
|
|
18
|
+
constructor(status, error, description) {
|
|
19
|
+
super(description);
|
|
20
|
+
this.name = 'OAuthHttpError';
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.error = error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nowIso(clock) {
|
|
27
|
+
return new Date(clock()).toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function token() {
|
|
31
|
+
return randomBytes(TOKEN_BYTES).toString('base64url');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function digest(value) {
|
|
35
|
+
return createHash('sha256').update(String(value), 'utf8').digest('base64url');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function secretMatches(value, expectedHash) {
|
|
39
|
+
const actual = Buffer.from(digest(value), 'base64url');
|
|
40
|
+
const expected = Buffer.from(expectedHash || '', 'base64url');
|
|
41
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function boundedInteger(value, name, minimum, maximum, fallback) {
|
|
45
|
+
const resolved = value === undefined ? fallback : value;
|
|
46
|
+
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
47
|
+
throw new TypeError(`${name} must be an integer from ${minimum} through ${maximum}`);
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function exactHttpsOrLoopbackUrl(value, name, { allowPath = true } = {}) {
|
|
53
|
+
const url = new URL(String(value));
|
|
54
|
+
const loopback = ['127.0.0.1', '::1', 'localhost'].includes(url.hostname);
|
|
55
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
56
|
+
throw new TypeError(`${name} must use HTTPS outside localhost`);
|
|
57
|
+
}
|
|
58
|
+
if (url.username || url.password || url.hash) throw new TypeError(`${name} must not contain credentials or a fragment`);
|
|
59
|
+
if (!allowPath && (url.pathname !== '/' || url.search)) throw new TypeError(`${name} must contain only scheme, host, and port`);
|
|
60
|
+
return url;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalResource(value) {
|
|
64
|
+
const url = exactHttpsOrLoopbackUrl(value, 'resource');
|
|
65
|
+
if (url.search) throw new TypeError('resource must not contain a query');
|
|
66
|
+
if (url.pathname === '/') url.pathname = '';
|
|
67
|
+
return url.toString().replace(/\/$/, '');
|
|
68
|
+
}
|
|
69
|
+
function nativeRedirectSchemes(value) {
|
|
70
|
+
const entries = value === undefined ? DEFAULT_NATIVE_REDIRECT_SCHEMES : value;
|
|
71
|
+
if (!Array.isArray(entries)) throw new TypeError('nativeRedirectSchemes must be an array');
|
|
72
|
+
const schemes = entries.map((entry) => String(entry).trim().toLowerCase());
|
|
73
|
+
if (schemes.some((scheme) => !/^[a-z][a-z0-9+.-]*$/.test(scheme) || scheme === 'http' || scheme === 'https')) {
|
|
74
|
+
throw new TypeError('nativeRedirectSchemes contains an invalid private-use URI scheme');
|
|
75
|
+
}
|
|
76
|
+
return new Set(schemes);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
function normalizeRedirectUri(value, allowedNativeSchemes = new Set()) {
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(String(value));
|
|
83
|
+
const scheme = url.protocol.slice(0, -1).toLowerCase();
|
|
84
|
+
if (allowedNativeSchemes.has(scheme)) {
|
|
85
|
+
if (!url.hostname || url.username || url.password || url.hash) throw new Error();
|
|
86
|
+
return url.toString();
|
|
87
|
+
}
|
|
88
|
+
return exactHttpsOrLoopbackUrl(value, 'redirect_uri').toString();
|
|
89
|
+
} catch {
|
|
90
|
+
throw new OAuthHttpError(400, 'invalid_redirect_uri', 'redirect_uri must be HTTPS, loopback HTTP, or an approved native-app URI');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requestResource(value) {
|
|
95
|
+
try {
|
|
96
|
+
return canonicalResource(value);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new OAuthHttpError(400, 'invalid_target', 'resource must be a canonical HTTPS or loopback HTTP URI');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeScopes(value, supportedScopes, fallback = DEFAULT_SCOPES) {
|
|
103
|
+
const entries = Array.isArray(value) ? value : String(value || '').split(/\s+/);
|
|
104
|
+
const scopes = [...new Set(entries.map((entry) => String(entry).trim()).filter(Boolean))];
|
|
105
|
+
const resolved = scopes.length ? scopes : [...fallback];
|
|
106
|
+
if (resolved.some((scope) => !supportedScopes.has(scope))) throw new OAuthHttpError(400, 'invalid_scope', 'one or more requested scopes are unsupported');
|
|
107
|
+
return resolved.sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emptyStore() {
|
|
111
|
+
return { schema: STORE_SCHEMA, revision: 0, clients: {}, codes: {}, accessTokens: {}, refreshTokens: {} };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cleanStore(state, clock) {
|
|
115
|
+
const cutoff = clock();
|
|
116
|
+
for (const [key, record] of Object.entries(state.codes)) {
|
|
117
|
+
if (Date.parse(record.expiresAt) <= cutoff || record.usedAt) delete state.codes[key];
|
|
118
|
+
}
|
|
119
|
+
for (const [key, record] of Object.entries(state.accessTokens)) {
|
|
120
|
+
if (Date.parse(record.expiresAt) <= cutoff || record.revokedAt) delete state.accessTokens[key];
|
|
121
|
+
}
|
|
122
|
+
for (const [key, record] of Object.entries(state.refreshTokens)) {
|
|
123
|
+
const terminal = record.revokedAt || (record.usedAt && Date.parse(record.usedAt) + 24 * 60 * 60_000 <= cutoff);
|
|
124
|
+
if (Date.parse(record.expiresAt) <= cutoff || terminal) delete state.refreshTokens[key];
|
|
125
|
+
}
|
|
126
|
+
return state;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createStoreAdapter(load, save, clock) {
|
|
130
|
+
let queue = Promise.resolve();
|
|
131
|
+
const transaction = (operation) => {
|
|
132
|
+
const run = queue.then(async () => {
|
|
133
|
+
const state = cleanStore(await load(), clock);
|
|
134
|
+
const output = await operation(state);
|
|
135
|
+
state.revision += 1;
|
|
136
|
+
await save(state);
|
|
137
|
+
return output;
|
|
138
|
+
});
|
|
139
|
+
queue = run.catch(() => undefined);
|
|
140
|
+
return run;
|
|
141
|
+
};
|
|
142
|
+
const view = async (operation) => {
|
|
143
|
+
await queue;
|
|
144
|
+
return operation(cleanStore(await load(), clock));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return Object.freeze({
|
|
148
|
+
async putClient(record) {
|
|
149
|
+
return transaction((state) => {
|
|
150
|
+
state.clients[record.clientId] = structuredClone(record);
|
|
151
|
+
return structuredClone(record);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
async getClient(clientId) {
|
|
155
|
+
return view((state) => state.clients[clientId] ? structuredClone(state.clients[clientId]) : null);
|
|
156
|
+
},
|
|
157
|
+
async putCode(codeValue, record) {
|
|
158
|
+
return transaction((state) => { state.codes[digest(codeValue)] = structuredClone(record); });
|
|
159
|
+
},
|
|
160
|
+
async takeCode(codeValue) {
|
|
161
|
+
return transaction((state) => {
|
|
162
|
+
const key = digest(codeValue);
|
|
163
|
+
const record = state.codes[key];
|
|
164
|
+
if (!record || record.usedAt) return null;
|
|
165
|
+
record.usedAt = nowIso(clock);
|
|
166
|
+
return structuredClone(record);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
async putAccessToken(tokenValue, record) {
|
|
170
|
+
return transaction((state) => { state.accessTokens[digest(tokenValue)] = structuredClone(record); });
|
|
171
|
+
},
|
|
172
|
+
async getAccessToken(tokenValue) {
|
|
173
|
+
return view((state) => {
|
|
174
|
+
const record = state.accessTokens[digest(tokenValue)];
|
|
175
|
+
return record && !record.revokedAt ? structuredClone(record) : null;
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
async putRefreshToken(tokenValue, record) {
|
|
179
|
+
return transaction((state) => { state.refreshTokens[digest(tokenValue)] = structuredClone(record); });
|
|
180
|
+
},
|
|
181
|
+
async rotateRefreshToken(tokenValue) {
|
|
182
|
+
return transaction((state) => {
|
|
183
|
+
const record = state.refreshTokens[digest(tokenValue)];
|
|
184
|
+
if (!record) return { status: 'missing', record: null };
|
|
185
|
+
if (record.revokedAt) return { status: 'revoked', record: structuredClone(record) };
|
|
186
|
+
if (record.usedAt) return { status: 'reused', record: structuredClone(record) };
|
|
187
|
+
record.usedAt = nowIso(clock);
|
|
188
|
+
return { status: 'active', record: structuredClone(record) };
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
async revokeFamily(familyId) {
|
|
192
|
+
return transaction((state) => {
|
|
193
|
+
const revokedAt = nowIso(clock);
|
|
194
|
+
for (const record of Object.values(state.refreshTokens)) {
|
|
195
|
+
if (record.familyId === familyId) record.revokedAt = revokedAt;
|
|
196
|
+
}
|
|
197
|
+
for (const record of Object.values(state.accessTokens)) {
|
|
198
|
+
if (record.familyId === familyId) record.revokedAt = revokedAt;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createEphemeralOAuthStore(options = {}) {
|
|
206
|
+
const clock = options.clock || Date.now;
|
|
207
|
+
let state = emptyStore();
|
|
208
|
+
return createStoreAdapter(async () => structuredClone(state), async (next) => { state = structuredClone(next); }, clock);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createFileOAuthStore(options = {}) {
|
|
212
|
+
const clock = options.clock || Date.now;
|
|
213
|
+
const path = resolve(String(options.path || '.enigma/oauth-store.json'));
|
|
214
|
+
async function load() {
|
|
215
|
+
try {
|
|
216
|
+
const state = JSON.parse(await readFile(path, 'utf8'));
|
|
217
|
+
if (state?.schema !== STORE_SCHEMA) throw new Error('unsupported OAuth store schema');
|
|
218
|
+
return state;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error.code === 'ENOENT') return emptyStore();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function save(state) {
|
|
225
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
226
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
227
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
228
|
+
try {
|
|
229
|
+
await handle.writeFile(JSON.stringify(state));
|
|
230
|
+
await handle.sync();
|
|
231
|
+
} finally {
|
|
232
|
+
await handle.close();
|
|
233
|
+
}
|
|
234
|
+
await rename(temporary, path);
|
|
235
|
+
}
|
|
236
|
+
return createStoreAdapter(load, save, clock);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseBasicAuthorization(value) {
|
|
240
|
+
if (!value || !String(value).startsWith('Basic ')) return null;
|
|
241
|
+
try {
|
|
242
|
+
const decoded = Buffer.from(String(value).slice(6), 'base64').toString('utf8');
|
|
243
|
+
const separator = decoded.indexOf(':');
|
|
244
|
+
if (separator < 0) return null;
|
|
245
|
+
return { clientId: decodeURIComponent(decoded.slice(0, separator)), clientSecret: decodeURIComponent(decoded.slice(separator + 1)) };
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readBody(request, maximum, expectedContentType) {
|
|
252
|
+
const contentType = String(request.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
|
|
253
|
+
if (contentType !== expectedContentType) throw new OAuthHttpError(415, 'invalid_request', `Content-Type must be ${expectedContentType}`);
|
|
254
|
+
const chunks = [];
|
|
255
|
+
let size = 0;
|
|
256
|
+
let tooLarge = false;
|
|
257
|
+
for await (const chunk of request) {
|
|
258
|
+
size += chunk.length;
|
|
259
|
+
if (size > maximum) tooLarge = true;
|
|
260
|
+
else chunks.push(chunk);
|
|
261
|
+
}
|
|
262
|
+
if (tooLarge) throw new OAuthHttpError(413, 'invalid_request', 'request body is too large');
|
|
263
|
+
return Buffer.concat(chunks, size).toString('utf8');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function json(response, status, payload) {
|
|
267
|
+
const body = JSON.stringify(payload);
|
|
268
|
+
response.statusCode = status;
|
|
269
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
270
|
+
response.setHeader('Content-Length', Buffer.byteLength(body));
|
|
271
|
+
response.end(body);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function securityHeaders(response) {
|
|
275
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
276
|
+
response.setHeader('Pragma', 'no-cache');
|
|
277
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
278
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
279
|
+
response.setHeader('X-Frame-Options', 'DENY');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function clientPublicView(client, includeSecret = null) {
|
|
283
|
+
return {
|
|
284
|
+
client_id: client.clientId,
|
|
285
|
+
client_id_issued_at: Math.floor(Date.parse(client.createdAt) / 1_000),
|
|
286
|
+
client_name: client.clientName,
|
|
287
|
+
redirect_uris: [...client.redirectUris],
|
|
288
|
+
grant_types: [...client.grantTypes],
|
|
289
|
+
response_types: ['code'],
|
|
290
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
291
|
+
scope: client.scopes.join(' '),
|
|
292
|
+
...(includeSecret ? { client_secret: includeSecret, client_secret_expires_at: 0 } : {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function createOAuthAuthorizationServer(options = {}) {
|
|
297
|
+
const issuer = exactHttpsOrLoopbackUrl(options.issuer, 'issuer', { allowPath: false }).toString().replace(/\/$/, '');
|
|
298
|
+
const resource = canonicalResource(options.resource);
|
|
299
|
+
const store = options.store;
|
|
300
|
+
if (!store || typeof store.getClient !== 'function' || typeof store.getAccessToken !== 'function') throw new TypeError('OAuth store is required');
|
|
301
|
+
if (typeof options.approveAuthorization !== 'function') throw new TypeError('approveAuthorization is required; Enigma never silently grants user access');
|
|
302
|
+
const supportedScopes = new Set(options.scopes || ENIGMA_OAUTH_SUPPORTED_SCOPES);
|
|
303
|
+
const allowedNativeRedirectSchemes = nativeRedirectSchemes(options.nativeRedirectSchemes);
|
|
304
|
+
const clock = options.clock || Date.now;
|
|
305
|
+
const accessTokenTtlMs = boundedInteger(options.accessTokenTtlMs, 'accessTokenTtlMs', 60_000, 60 * 60_000, 10 * 60_000);
|
|
306
|
+
const refreshTokenTtlMs = boundedInteger(options.refreshTokenTtlMs, 'refreshTokenTtlMs', 60_000, 90 * 24 * 60 * 60_000, 30 * 24 * 60 * 60_000);
|
|
307
|
+
const codeTtlMs = boundedInteger(options.codeTtlMs, 'codeTtlMs', 30_000, 10 * 60_000, 2 * 60_000);
|
|
308
|
+
const maxBodyBytes = boundedInteger(options.maxBodyBytes, 'maxBodyBytes', 1_024, 1_048_576, 64 * 1_024);
|
|
309
|
+
const authorizationEndpoint = `${issuer}/authorize`;
|
|
310
|
+
const tokenEndpoint = `${issuer}/token`;
|
|
311
|
+
const registrationEndpoint = `${issuer}/register`;
|
|
312
|
+
const metadata = Object.freeze({
|
|
313
|
+
issuer,
|
|
314
|
+
authorization_endpoint: authorizationEndpoint,
|
|
315
|
+
token_endpoint: tokenEndpoint,
|
|
316
|
+
...(typeof options.authorizeRegistration === 'function' ? { registration_endpoint: registrationEndpoint } : {}),
|
|
317
|
+
response_types_supported: ['code'],
|
|
318
|
+
response_modes_supported: ['query'],
|
|
319
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
320
|
+
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
|
|
321
|
+
code_challenge_methods_supported: ['S256'],
|
|
322
|
+
scopes_supported: [...supportedScopes].sort(),
|
|
323
|
+
authorization_response_iss_parameter_supported: true,
|
|
324
|
+
});
|
|
325
|
+
const protectedResourceMetadata = Object.freeze({
|
|
326
|
+
resource,
|
|
327
|
+
authorization_servers: [issuer],
|
|
328
|
+
bearer_methods_supported: ['header'],
|
|
329
|
+
scopes_supported: [...supportedScopes].sort(),
|
|
330
|
+
resource_name: 'Enigma Platform',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
async function registerClient(input, context = {}) {
|
|
334
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new OAuthHttpError(400, 'invalid_client_metadata', 'client metadata must be an object');
|
|
335
|
+
if (!Array.isArray(input.redirect_uris)) throw new OAuthHttpError(400, 'invalid_redirect_uri', 'redirect_uris must be an array');
|
|
336
|
+
const redirectUris = [...new Set(input.redirect_uris.map((value) => normalizeRedirectUri(value, allowedNativeRedirectSchemes)))];
|
|
337
|
+
if (!redirectUris.length || redirectUris.length > 16) throw new OAuthHttpError(400, 'invalid_redirect_uri', 'one through sixteen redirect_uris are required');
|
|
338
|
+
if (input.grant_types !== undefined && !Array.isArray(input.grant_types)) throw new OAuthHttpError(400, 'invalid_client_metadata', 'grant_types must be an array');
|
|
339
|
+
const grantTypes = [...new Set(input.grant_types || ['authorization_code', 'refresh_token'])];
|
|
340
|
+
if (grantTypes.some((grant) => !['authorization_code', 'refresh_token'].includes(grant)) || !grantTypes.includes('authorization_code')) {
|
|
341
|
+
throw new OAuthHttpError(400, 'invalid_client_metadata', 'only authorization_code and refresh_token grants are supported');
|
|
342
|
+
}
|
|
343
|
+
const authMethod = input.token_endpoint_auth_method || 'none';
|
|
344
|
+
if (!['none', 'client_secret_basic', 'client_secret_post'].includes(authMethod)) {
|
|
345
|
+
throw new OAuthHttpError(400, 'invalid_client_metadata', 'unsupported token_endpoint_auth_method');
|
|
346
|
+
}
|
|
347
|
+
const scopes = normalizeScopes(input.scope, supportedScopes);
|
|
348
|
+
const clientId = randomUUID();
|
|
349
|
+
const clientSecret = authMethod === 'none' ? null : token();
|
|
350
|
+
const record = {
|
|
351
|
+
clientId,
|
|
352
|
+
clientName: String(input.client_name || 'MCP client').slice(0, 200),
|
|
353
|
+
redirectUris,
|
|
354
|
+
grantTypes,
|
|
355
|
+
tokenEndpointAuthMethod: authMethod,
|
|
356
|
+
clientSecretHash: clientSecret ? digest(clientSecret) : null,
|
|
357
|
+
scopes,
|
|
358
|
+
createdAt: nowIso(clock),
|
|
359
|
+
registrationContext: context.registrationContext || null,
|
|
360
|
+
};
|
|
361
|
+
await store.putClient(record);
|
|
362
|
+
return clientPublicView(record, clientSecret);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function authenticateClient(request, params) {
|
|
366
|
+
const basic = parseBasicAuthorization(request.headers.authorization);
|
|
367
|
+
const clientId = basic?.clientId || params.get('client_id');
|
|
368
|
+
if (!clientId) throw new OAuthHttpError(401, 'invalid_client', 'client authentication is required');
|
|
369
|
+
const client = await store.getClient(clientId);
|
|
370
|
+
if (!client) throw new OAuthHttpError(401, 'invalid_client', 'client authentication failed');
|
|
371
|
+
if (client.tokenEndpointAuthMethod === 'none') {
|
|
372
|
+
if (basic || params.has('client_secret')) throw new OAuthHttpError(401, 'invalid_client', 'public client must not send a client secret');
|
|
373
|
+
} else {
|
|
374
|
+
const methodMatches = client.tokenEndpointAuthMethod === 'client_secret_basic' ? Boolean(basic) : !basic;
|
|
375
|
+
const suppliedSecret = basic?.clientSecret || params.get('client_secret') || '';
|
|
376
|
+
if (!methodMatches || !secretMatches(suppliedSecret, client.clientSecretHash)) throw new OAuthHttpError(401, 'invalid_client', 'client authentication failed');
|
|
377
|
+
}
|
|
378
|
+
return client;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function issueTokens({ client, subject, tenantId, scopes, familyId = randomUUID() }) {
|
|
382
|
+
const issuedAt = clock();
|
|
383
|
+
const accessToken = token();
|
|
384
|
+
const accessRecord = {
|
|
385
|
+
tokenId: randomUUID(), familyId, clientId: client.clientId, subject, tenantId, scopes, resource,
|
|
386
|
+
issuedAt: new Date(issuedAt).toISOString(), expiresAt: new Date(issuedAt + accessTokenTtlMs).toISOString(), revokedAt: null,
|
|
387
|
+
};
|
|
388
|
+
await store.putAccessToken(accessToken, accessRecord);
|
|
389
|
+
let refreshToken = null;
|
|
390
|
+
if (scopes.includes('offline_access') && client.grantTypes.includes('refresh_token')) {
|
|
391
|
+
refreshToken = token();
|
|
392
|
+
await store.putRefreshToken(refreshToken, {
|
|
393
|
+
tokenId: randomUUID(), familyId, clientId: client.clientId, subject, tenantId, scopes, resource,
|
|
394
|
+
issuedAt: new Date(issuedAt).toISOString(), expiresAt: new Date(issuedAt + refreshTokenTtlMs).toISOString(), usedAt: null, revokedAt: null,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
access_token: accessToken,
|
|
399
|
+
token_type: 'Bearer',
|
|
400
|
+
expires_in: Math.floor(accessTokenTtlMs / 1_000),
|
|
401
|
+
scope: scopes.join(' '),
|
|
402
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function handleAuthorize(request, response, url) {
|
|
407
|
+
const query = url.searchParams;
|
|
408
|
+
if (query.get('response_type') !== 'code') throw new OAuthHttpError(400, 'unsupported_response_type', 'response_type must be code');
|
|
409
|
+
const client = await store.getClient(query.get('client_id'));
|
|
410
|
+
if (!client) throw new OAuthHttpError(400, 'invalid_request', 'unknown client_id');
|
|
411
|
+
const redirectUri = normalizeRedirectUri(query.get('redirect_uri'), allowedNativeRedirectSchemes);
|
|
412
|
+
if (!client.redirectUris.includes(redirectUri)) throw new OAuthHttpError(400, 'invalid_request', 'redirect_uri does not exactly match a registered URI');
|
|
413
|
+
if (query.get('resource') !== resource) throw new OAuthHttpError(400, 'invalid_target', 'resource must identify this protected resource');
|
|
414
|
+
const challenge = query.get('code_challenge');
|
|
415
|
+
if (!challenge || query.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
|
|
416
|
+
throw new OAuthHttpError(400, 'invalid_request', 'PKCE S256 code_challenge is required');
|
|
417
|
+
}
|
|
418
|
+
const scopes = normalizeScopes(query.get('scope'), supportedScopes);
|
|
419
|
+
if (scopes.some((scope) => !client.scopes.includes(scope))) throw new OAuthHttpError(400, 'invalid_scope', 'client is not registered for the requested scopes');
|
|
420
|
+
const decision = await options.approveAuthorization({
|
|
421
|
+
request,
|
|
422
|
+
client: clientPublicView(client),
|
|
423
|
+
requestedScopes: scopes,
|
|
424
|
+
resource,
|
|
425
|
+
state: query.get('state') || null,
|
|
426
|
+
});
|
|
427
|
+
const redirect = new URL(redirectUri);
|
|
428
|
+
if (!decision?.approved) {
|
|
429
|
+
redirect.searchParams.set('error', 'access_denied');
|
|
430
|
+
if (query.has('state')) redirect.searchParams.set('state', query.get('state'));
|
|
431
|
+
response.statusCode = 302;
|
|
432
|
+
response.setHeader('Location', redirect.toString());
|
|
433
|
+
response.end();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const tenantId = String(decision.tenantId || decision.tenant_id || 'default').trim();
|
|
437
|
+
if (!tenantId || tenantId.length > 512) throw new OAuthHttpError(500, 'server_error', 'authorization decision did not provide a valid tenant');
|
|
438
|
+
const subject = String(decision.subject || '').trim();
|
|
439
|
+
if (!subject || subject.length > 512) throw new OAuthHttpError(500, 'server_error', 'authorization decision did not provide a valid subject');
|
|
440
|
+
const grantedScopes = normalizeScopes(decision.scopes || scopes, supportedScopes).filter((scope) => scopes.includes(scope));
|
|
441
|
+
if (!grantedScopes.length) throw new OAuthHttpError(400, 'invalid_scope', 'authorization granted no requested scopes');
|
|
442
|
+
const codeValue = token();
|
|
443
|
+
await store.putCode(codeValue, {
|
|
444
|
+
codeId: randomUUID(), clientId: client.clientId, redirectUri, challenge, resource, subject, tenantId, scopes: grantedScopes,
|
|
445
|
+
issuedAt: nowIso(clock), expiresAt: new Date(clock() + codeTtlMs).toISOString(), usedAt: null,
|
|
446
|
+
});
|
|
447
|
+
redirect.searchParams.set('code', codeValue);
|
|
448
|
+
redirect.searchParams.set('iss', issuer);
|
|
449
|
+
if (query.has('state')) redirect.searchParams.set('state', query.get('state'));
|
|
450
|
+
response.statusCode = 302;
|
|
451
|
+
response.setHeader('Location', redirect.toString());
|
|
452
|
+
response.end();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function handleToken(request, response) {
|
|
456
|
+
const body = await readBody(request, maxBodyBytes, 'application/x-www-form-urlencoded');
|
|
457
|
+
const params = new URLSearchParams(body);
|
|
458
|
+
const client = await authenticateClient(request, params);
|
|
459
|
+
const requestedResource = requestResource(params.get('resource'));
|
|
460
|
+
if (requestedResource !== resource) throw new OAuthHttpError(400, 'invalid_target', 'resource must identify this protected resource');
|
|
461
|
+
const grantType = params.get('grant_type');
|
|
462
|
+
if (grantType === 'authorization_code') {
|
|
463
|
+
const codeValue = params.get('code') || '';
|
|
464
|
+
const code = await store.takeCode(codeValue);
|
|
465
|
+
if (!code || Date.parse(code.expiresAt) <= clock()) throw new OAuthHttpError(400, 'invalid_grant', 'authorization code is invalid, expired, or already used');
|
|
466
|
+
const redirectUri = normalizeRedirectUri(params.get('redirect_uri'), allowedNativeRedirectSchemes);
|
|
467
|
+
if (code.clientId !== client.clientId || code.redirectUri !== redirectUri || code.resource !== resource) {
|
|
468
|
+
throw new OAuthHttpError(400, 'invalid_grant', 'authorization code binding failed');
|
|
469
|
+
}
|
|
470
|
+
const verifier = params.get('code_verifier') || '';
|
|
471
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) throw new OAuthHttpError(400, 'invalid_grant', 'PKCE code_verifier is invalid');
|
|
472
|
+
const computedChallenge = createHash('sha256').update(verifier).digest('base64url');
|
|
473
|
+
const actual = Buffer.from(computedChallenge);
|
|
474
|
+
const expected = Buffer.from(code.challenge);
|
|
475
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new OAuthHttpError(400, 'invalid_grant', 'PKCE verification failed');
|
|
476
|
+
json(response, 200, await issueTokens({ client, subject: code.subject, tenantId: code.tenantId, scopes: code.scopes }));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (grantType === 'refresh_token') {
|
|
480
|
+
const rotated = await store.rotateRefreshToken(params.get('refresh_token') || '');
|
|
481
|
+
if (rotated.status === 'reused') {
|
|
482
|
+
await store.revokeFamily(rotated.record.familyId);
|
|
483
|
+
throw new OAuthHttpError(400, 'invalid_grant', 'refresh token reuse detected; token family revoked');
|
|
484
|
+
}
|
|
485
|
+
if (rotated.status !== 'active' || Date.parse(rotated.record.expiresAt) <= clock()) throw new OAuthHttpError(400, 'invalid_grant', 'refresh token is invalid or expired');
|
|
486
|
+
if (rotated.record.clientId !== client.clientId || rotated.record.resource !== resource) {
|
|
487
|
+
await store.revokeFamily(rotated.record.familyId);
|
|
488
|
+
throw new OAuthHttpError(400, 'invalid_grant', 'refresh token binding failed');
|
|
489
|
+
}
|
|
490
|
+
const requestedScopes = params.has('scope') ? normalizeScopes(params.get('scope'), supportedScopes) : rotated.record.scopes;
|
|
491
|
+
if (requestedScopes.some((scope) => !rotated.record.scopes.includes(scope))) throw new OAuthHttpError(400, 'invalid_scope', 'refresh cannot increase scope');
|
|
492
|
+
json(response, 200, await issueTokens({ client, subject: rotated.record.subject, tenantId: rotated.record.tenantId, scopes: requestedScopes, familyId: rotated.record.familyId }));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
throw new OAuthHttpError(400, 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function handler(request, response) {
|
|
499
|
+
securityHeaders(response);
|
|
500
|
+
try {
|
|
501
|
+
const url = new URL(request.url || '/', issuer);
|
|
502
|
+
if (request.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
|
|
503
|
+
json(response, 200, metadata);
|
|
504
|
+
} else if (request.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
|
|
505
|
+
json(response, 200, protectedResourceMetadata);
|
|
506
|
+
} else if (request.method === 'GET' && url.pathname === '/authorize') {
|
|
507
|
+
await handleAuthorize(request, response, url);
|
|
508
|
+
} else if (request.method === 'POST' && url.pathname === '/token') {
|
|
509
|
+
await handleToken(request, response);
|
|
510
|
+
} else if (request.method === 'POST' && url.pathname === '/register' && typeof options.authorizeRegistration === 'function') {
|
|
511
|
+
const body = await readBody(request, maxBodyBytes, 'application/json');
|
|
512
|
+
let input;
|
|
513
|
+
try { input = JSON.parse(body); } catch { throw new OAuthHttpError(400, 'invalid_client_metadata', 'registration body is not valid JSON'); }
|
|
514
|
+
const registrationContext = await options.authorizeRegistration({ request, input });
|
|
515
|
+
if (!registrationContext) throw new OAuthHttpError(403, 'registration_denied', 'dynamic client registration denied');
|
|
516
|
+
json(response, 201, await registerClient(input, { registrationContext }));
|
|
517
|
+
} else {
|
|
518
|
+
throw new OAuthHttpError(404, 'not_found', 'OAuth endpoint not found');
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (response.writableEnded) return;
|
|
522
|
+
const status = error instanceof OAuthHttpError ? error.status : 500;
|
|
523
|
+
json(response, status, {
|
|
524
|
+
error: error instanceof OAuthHttpError ? error.error : 'server_error',
|
|
525
|
+
error_description: error instanceof OAuthHttpError ? error.message : 'internal server error',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function authorizeBearer({ authorization, requiredScopes = ['mcp:invoke'] } = {}) {
|
|
531
|
+
if (!authorization || !String(authorization).startsWith('Bearer ')) return null;
|
|
532
|
+
const tokenValue = String(authorization).slice(7);
|
|
533
|
+
if (!tokenValue || tokenValue.includes(' ')) return null;
|
|
534
|
+
const record = await store.getAccessToken(tokenValue);
|
|
535
|
+
if (!record || record.resource !== resource || Date.parse(record.expiresAt) <= clock()) return null;
|
|
536
|
+
if (requiredScopes.some((scope) => !record.scopes.includes(scope))) return null;
|
|
537
|
+
return Object.freeze({ subject: record.subject, tenantId: record.tenantId || 'default', clientId: record.clientId, scopes: [...record.scopes], resource: record.resource, tokenId: record.tokenId });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return Object.freeze({ handler, metadata, protectedResourceMetadata, registerClient, authorizeBearer, issuer, resource });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function startOAuthAuthorizationServer(options = {}) {
|
|
544
|
+
const host = String(options.host || '127.0.0.1');
|
|
545
|
+
const port = boundedInteger(options.port, 'port', 0, 65_535, 0);
|
|
546
|
+
if (!['127.0.0.1', '::1', 'localhost'].includes(host) && options.allowPublicListener !== true) {
|
|
547
|
+
throw new Error('public OAuth listeners require allowPublicListener=true and an external TLS terminator');
|
|
548
|
+
}
|
|
549
|
+
const oauth = createOAuthAuthorizationServer(options);
|
|
550
|
+
const server = createServer((request, response) => oauth.handler(request, response));
|
|
551
|
+
await new Promise((resolveListen, reject) => {
|
|
552
|
+
server.once('error', reject);
|
|
553
|
+
server.listen(port, host, resolveListen);
|
|
554
|
+
});
|
|
555
|
+
return Object.freeze({
|
|
556
|
+
...oauth,
|
|
557
|
+
server,
|
|
558
|
+
address: server.address(),
|
|
559
|
+
close: () => new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const ENIGMA_PRIVATE_PROTOCOL = 'enigma:';
|
|
2
|
+
export const ENIGMA_PRIVATE_HOST = 'private';
|
|
3
|
+
export const PRIVATE_SESSION_RESOURCE_URI = 'ui://enigma/private-session.html';
|
|
4
|
+
export const DEFAULT_DESKTOP_ORIGIN = 'http://127.0.0.1:8765';
|
|
5
|
+
|
|
6
|
+
const SENSITIVE_HANDOFF_PATTERN = /[\r\n]|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|0x[a-f0-9]{40}|\b(?:\d{1,3}\.){3}\d{1,3}\b/i;
|
|
7
|
+
|
|
8
|
+
export function validatePrivateHandoffIntent(value) {
|
|
9
|
+
if (value === undefined || value === null || value === '') return null;
|
|
10
|
+
if (typeof value !== 'string') throw new TypeError('Private handoff intent must be a string.');
|
|
11
|
+
const intent = value.trim();
|
|
12
|
+
if (!intent || intent.length > 160 || SENSITIVE_HANDOFF_PATTERN.test(intent)) {
|
|
13
|
+
throw new TypeError('Private handoff intent must be a short non-sensitive label; type the actual private prompt inside Enigma.');
|
|
14
|
+
}
|
|
15
|
+
return intent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validatePrivateHandoffModel(value) {
|
|
19
|
+
if (value === undefined || value === null || value === '') return null;
|
|
20
|
+
if (typeof value !== 'string') throw new TypeError('Private handoff model must be a string.');
|
|
21
|
+
const model = value.trim();
|
|
22
|
+
if (!model || model.length > 160 || /[\r\n]/.test(model)) {
|
|
23
|
+
throw new TypeError('Private handoff model must be a non-empty route label no longer than 160 characters.');
|
|
24
|
+
}
|
|
25
|
+
return model;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateLoopbackDesktopOrigin(value) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(String(value));
|
|
32
|
+
} catch {
|
|
33
|
+
throw new TypeError('Desktop origin must be a valid loopback HTTP URL.');
|
|
34
|
+
}
|
|
35
|
+
const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost';
|
|
36
|
+
if (parsed.protocol !== 'http:' || !loopback || parsed.username || parsed.password) {
|
|
37
|
+
throw new TypeError('Desktop origin must use unauthenticated loopback HTTP.');
|
|
38
|
+
}
|
|
39
|
+
return parsed.origin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendHandoffParameters(target, input = {}) {
|
|
43
|
+
const intent = validatePrivateHandoffIntent(input.intent);
|
|
44
|
+
const model = validatePrivateHandoffModel(input.model);
|
|
45
|
+
if (intent) target.searchParams.set('intent', intent);
|
|
46
|
+
if (model) target.searchParams.set('model', model);
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createPrivateSessionProtocolUrl(input = {}) {
|
|
51
|
+
const target = new URL(`${ENIGMA_PRIVATE_PROTOCOL}//${ENIGMA_PRIVATE_HOST}`);
|
|
52
|
+
appendHandoffParameters(target, input);
|
|
53
|
+
return target.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createPrivateSessionHttpUrl(input = {}, desktopOrigin = DEFAULT_DESKTOP_ORIGIN) {
|
|
57
|
+
const target = new URL(validateLoopbackDesktopOrigin(desktopOrigin));
|
|
58
|
+
target.searchParams.set('source', 'mcp');
|
|
59
|
+
target.searchParams.set('mode', 'private');
|
|
60
|
+
appendHandoffParameters(target, input);
|
|
61
|
+
target.hash = 'workspace';
|
|
62
|
+
return target.toString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createWorkspaceUrlFromProtocol(protocolUrl, desktopOrigin) {
|
|
66
|
+
let handoff;
|
|
67
|
+
try {
|
|
68
|
+
handoff = new URL(String(protocolUrl));
|
|
69
|
+
} catch {
|
|
70
|
+
throw new TypeError('Private handoff URL is invalid.');
|
|
71
|
+
}
|
|
72
|
+
if (handoff.protocol !== ENIGMA_PRIVATE_PROTOCOL || handoff.hostname !== ENIGMA_PRIVATE_HOST) {
|
|
73
|
+
throw new TypeError('Private handoff URL must use enigma://private.');
|
|
74
|
+
}
|
|
75
|
+
return createPrivateSessionHttpUrl({
|
|
76
|
+
intent: handoff.searchParams.get('intent') || undefined,
|
|
77
|
+
model: handoff.searchParams.get('model') || undefined,
|
|
78
|
+
}, desktopOrigin);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findPrivateSessionProtocolUrl(argv = []) {
|
|
82
|
+
if (!Array.isArray(argv)) return null;
|
|
83
|
+
return argv.find((value) => typeof value === 'string' && value.toLowerCase().startsWith('enigma://private')) || null;
|
|
84
|
+
}
|