@frontmcp/auth 1.10.15 → 1.11.0-rc.1
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/browser/authorities/authorities.context.mjs +115 -0
- package/browser/authorities/authorities.engine.mjs +272 -0
- package/browser/authorities/authorities.errors.mjs +59 -0
- package/browser/authorities/authorities.evaluator.mjs +225 -0
- package/browser/authorities/authorities.metadata-augment.mjs +18 -0
- package/browser/authorities/authorities.registry.mjs +112 -0
- package/browser/authorities/authorities.schema.mjs +92 -0
- package/browser/authorities/authorities.scope-mapping.mjs +30 -0
- package/browser/authorities/authorities.validation.mjs +208 -0
- package/browser/authorities/index.mjs +43 -0
- package/browser/authorization/authorization.class.mjs +252 -0
- package/browser/authorization/authorization.types.mjs +64 -0
- package/browser/authorization/dcr-client.registry.mjs +222 -0
- package/browser/authorization/index.mjs +41 -0
- package/browser/authorization/orchestrated.accessor.mjs +86 -0
- package/browser/authorization/orchestrated.authorization.mjs +289 -0
- package/browser/authorization/public.authorization.mjs +124 -0
- package/browser/authorization/transparent.authorization.mjs +130 -0
- package/browser/cimd/cimd-redis.cache.mjs +231 -0
- package/browser/cimd/cimd.cache.mjs +215 -0
- package/browser/cimd/cimd.errors.mjs +142 -0
- package/browser/cimd/cimd.logger.mjs +15 -0
- package/browser/cimd/cimd.service.mjs +376 -0
- package/browser/cimd/cimd.types.mjs +228 -0
- package/browser/cimd/cimd.validator.mjs +217 -0
- package/browser/cimd/index.mjs +63 -0
- package/browser/common/auth-logger.interface.mjs +14 -0
- package/browser/common/index.mjs +6 -0
- package/browser/common/jwt.types.mjs +38 -0
- package/browser/common/session-user.types.mjs +0 -0
- package/browser/common/session.types.mjs +58 -0
- package/browser/common/zod-utils.mjs +0 -0
- package/browser/consent/consent.types.mjs +94 -0
- package/browser/consent/index.mjs +17 -0
- package/browser/context/frontmcp-auth-context.factory.mjs +29 -0
- package/browser/context/frontmcp-auth-context.impl.mjs +112 -0
- package/browser/context/index.mjs +7 -0
- package/browser/detection/auth-provider-detection.mjs +176 -0
- package/browser/detection/index.mjs +19 -0
- package/browser/errors/auth-internal.error.mjs +42 -0
- package/browser/errors/auth-internal.errors.mjs +150 -0
- package/browser/errors/index.mjs +53 -0
- package/browser/fetch/fetch-credential-middleware.mjs +91 -0
- package/browser/fetch/index.mjs +19 -0
- package/browser/index.mjs +573 -0
- package/browser/jwks/index.mjs +9 -0
- package/browser/jwks/jose.loader.mjs +28 -0
- package/browser/jwks/jwks.service.mjs +521 -0
- package/browser/jwks/jwks.utils.mjs +33 -0
- package/browser/options/app-auth.schema.mjs +18 -0
- package/browser/options/index.mjs +89 -0
- package/browser/options/orchestrated.schema.mjs +128 -0
- package/browser/options/public.schema.mjs +39 -0
- package/browser/options/schema.mjs +16 -0
- package/browser/options/shared.schemas.mjs +506 -0
- package/browser/options/static.schema.mjs +41 -0
- package/browser/options/transparent.schema.mjs +43 -0
- package/browser/options/utils.mjs +47 -0
- package/browser/people/index.mjs +15 -0
- package/browser/people/loopback-token.auth.mjs +115 -0
- package/browser/people/people-auth.options.mjs +37 -0
- package/browser/session/authorization-vault.mjs +279 -0
- package/browser/session/authorization.store.mjs +336 -0
- package/browser/session/consent.store.mjs +34 -0
- package/browser/session/credential-resume-link.mjs +43 -0
- package/browser/session/credentials-accessor.impl.mjs +54 -0
- package/browser/session/credentials-accessor.mjs +5 -0
- package/browser/session/encrypted-authorization-vault.mjs +405 -0
- package/browser/session/federated-auth.session.mjs +176 -0
- package/browser/session/incremental-auth-ticket.mjs +42 -0
- package/browser/session/index.mjs +266 -0
- package/browser/session/orchestrated-token.crypto.mjs +47 -0
- package/browser/session/orchestrated-token.store.mjs +286 -0
- package/browser/session/pending-login.mjs +37 -0
- package/browser/session/redis-session.store.mjs +255 -0
- package/browser/session/secure-store-accessor.impl.mjs +78 -0
- package/browser/session/secure-store-accessor.mjs +5 -0
- package/browser/session/secure-store-backends.mjs +111 -0
- package/browser/session/secure-store.factory.mjs +77 -0
- package/browser/session/session-credential-vault.factory.mjs +18 -0
- package/browser/session/session-credential-vault.mjs +243 -0
- package/browser/session/session-crypto.mjs +39 -0
- package/browser/session/session-rate-limiter.mjs +126 -0
- package/browser/session/session.transport.mjs +16 -0
- package/browser/session/signin-binding.mjs +30 -0
- package/browser/session/storage/in-memory-authorization-vault.mjs +28 -0
- package/browser/session/storage/index.mjs +14 -0
- package/browser/session/storage/storage-authorization-vault.mjs +301 -0
- package/browser/session/storage/storage-token-store.mjs +83 -0
- package/browser/session/storage-authorization.store.mjs +134 -0
- package/browser/session/storage-consent.store.mjs +33 -0
- package/browser/session/storage-federated-auth.session.mjs +55 -0
- package/browser/session/storage-orchestrated-token.store.mjs +197 -0
- package/browser/session/token-storage.factory.mjs +58 -0
- package/browser/session/token.refresh.mjs +36 -0
- package/browser/session/token.vault.mjs +61 -0
- package/browser/session/transport-session.types.mjs +108 -0
- package/browser/session/utils/auth-token.utils.mjs +58 -0
- package/browser/session/utils/index.mjs +23 -0
- package/browser/session/utils/session-crypto.utils.mjs +49 -0
- package/browser/session/utils/tiny-ttl-cache.mjs +46 -0
- package/browser/session/vault-encryption.mjs +187 -0
- package/browser/session/vercel-kv-session.store.mjs +230 -0
- package/browser/shared-server/index.mjs +17 -0
- package/browser/shared-server/shared-server-identity.mjs +55 -0
- package/browser/ui/base-layout.mjs +184 -0
- package/browser/ui/index.mjs +51 -0
- package/browser/ui/local-login.helper.mjs +46 -0
- package/browser/ui/templates.mjs +482 -0
- package/browser/utils/audience.validator.mjs +110 -0
- package/browser/utils/authorization-id.utils.mjs +10 -0
- package/browser/utils/index.mjs +45 -0
- package/browser/utils/redirect-uri.utils.mjs +22 -0
- package/browser/utils/scope-grant.utils.mjs +36 -0
- package/browser/utils/token-scopes.utils.mjs +12 -0
- package/browser/utils/www-authenticate.utils.mjs +164 -0
- package/browser/vault/auth-providers.accessor.impl.mjs +236 -0
- package/browser/vault/auth-providers.accessor.mjs +5 -0
- package/browser/vault/auth-providers.registry.mjs +140 -0
- package/browser/vault/auth-providers.types.mjs +47 -0
- package/browser/vault/auth-providers.vault.mjs +206 -0
- package/browser/vault/credential-cache.mjs +187 -0
- package/browser/vault/credential-helpers.mjs +15 -0
- package/browser/vault/credential-loaders/eager-loader.mjs +91 -0
- package/browser/vault/credential-loaders/lazy-loader.mjs +120 -0
- package/browser/vault/index.mjs +35 -0
- package/cimd/cimd.types.d.ts +1 -1
- package/cimd/cimd.types.d.ts.map +1 -1
- package/common/jwt.types.d.ts +1 -1
- package/common/jwt.types.d.ts.map +1 -1
- package/common/session.types.d.ts +9 -1
- package/common/session.types.d.ts.map +1 -1
- package/consent/consent.types.d.ts +1 -1
- package/consent/consent.types.d.ts.map +1 -1
- package/detection/auth-provider-detection.d.ts +1 -1
- package/detection/auth-provider-detection.d.ts.map +1 -1
- package/errors/auth-internal.errors.d.ts +6 -0
- package/errors/auth-internal.errors.d.ts.map +1 -1
- package/errors/index.d.ts +1 -1
- package/errors/index.d.ts.map +1 -1
- package/esm/index.mjs +2 -2
- package/esm/package.json +21 -5
- package/index.d.ts +5 -1
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/options/app-auth.schema.d.ts +19 -1
- package/options/app-auth.schema.d.ts.map +1 -1
- package/options/orchestrated.schema.d.ts +37 -1
- package/options/orchestrated.schema.d.ts.map +1 -1
- package/options/public.schema.d.ts +1 -1
- package/options/public.schema.d.ts.map +1 -1
- package/options/schema.d.ts +19 -1
- package/options/schema.d.ts.map +1 -1
- package/options/shared.schemas.d.ts +10 -14
- package/options/shared.schemas.d.ts.map +1 -1
- package/options/static.schema.d.ts +1 -1
- package/options/static.schema.d.ts.map +1 -1
- package/options/transparent.schema.d.ts +1 -1
- package/options/transparent.schema.d.ts.map +1 -1
- package/package.json +21 -5
- package/people/index.d.ts +3 -0
- package/people/index.d.ts.map +1 -0
- package/people/loopback-token.auth.d.ts +75 -0
- package/people/loopback-token.auth.d.ts.map +1 -0
- package/people/people-auth.options.d.ts +36 -0
- package/people/people-auth.options.d.ts.map +1 -0
- package/session/authorization-vault.d.ts +1 -1
- package/session/authorization-vault.d.ts.map +1 -1
- package/session/encrypted-authorization-vault.d.ts +1 -1
- package/session/encrypted-authorization-vault.d.ts.map +1 -1
- package/session/secure-store.factory.d.ts.map +1 -1
- package/shared-server/index.d.ts +3 -0
- package/shared-server/index.d.ts.map +1 -0
- package/shared-server/shared-server-identity.d.ts +26 -0
- package/shared-server/shared-server-identity.d.ts.map +1 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// libs/auth/src/session/vault-encryption.ts
|
|
2
|
+
import { z } from "@frontmcp/lazy-zod";
|
|
3
|
+
import {
|
|
4
|
+
base64urlDecode,
|
|
5
|
+
base64urlEncode,
|
|
6
|
+
decryptAesGcm,
|
|
7
|
+
encryptAesGcm,
|
|
8
|
+
hkdfSha256,
|
|
9
|
+
randomBytes
|
|
10
|
+
} from "@frontmcp/utils";
|
|
11
|
+
var encryptedDataSchema = z.object({
|
|
12
|
+
/** Version for future algorithm changes */
|
|
13
|
+
v: z.literal(1),
|
|
14
|
+
/** Algorithm identifier */
|
|
15
|
+
alg: z.literal("aes-256-gcm"),
|
|
16
|
+
/** Initialization vector (base64) */
|
|
17
|
+
iv: z.string(),
|
|
18
|
+
/** Ciphertext (base64) */
|
|
19
|
+
ct: z.string(),
|
|
20
|
+
/** Authentication tag (base64) */
|
|
21
|
+
tag: z.string()
|
|
22
|
+
});
|
|
23
|
+
var VaultEncryption = class {
|
|
24
|
+
pepper;
|
|
25
|
+
hkdfInfo;
|
|
26
|
+
constructor(config = {}) {
|
|
27
|
+
this.pepper = new TextEncoder().encode(config.pepper ?? "");
|
|
28
|
+
this.hkdfInfo = config.hkdfInfo ?? "frontmcp-vault-v1";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Derive an encryption key from JWT claims
|
|
32
|
+
*
|
|
33
|
+
* The key derivation uses HKDF:
|
|
34
|
+
* 1. Combine jti + vaultKey + sub + iat + pepper as IKM
|
|
35
|
+
* 2. Apply HKDF-SHA256 to derive a 256-bit key
|
|
36
|
+
*
|
|
37
|
+
* @param claims - JWT claims containing key material
|
|
38
|
+
* @returns 32-byte encryption key as Uint8Array
|
|
39
|
+
*/
|
|
40
|
+
async deriveKey(claims) {
|
|
41
|
+
const ikmParts = [
|
|
42
|
+
claims.jti,
|
|
43
|
+
claims.vaultKey ?? "",
|
|
44
|
+
claims.sub,
|
|
45
|
+
claims.iat.toString(),
|
|
46
|
+
new TextDecoder().decode(this.pepper)
|
|
47
|
+
];
|
|
48
|
+
const ikm = new TextEncoder().encode(ikmParts.join(""));
|
|
49
|
+
const infoBytes = new TextEncoder().encode(this.hkdfInfo);
|
|
50
|
+
const key = await hkdfSha256(ikm, new Uint8Array(0), infoBytes, 32);
|
|
51
|
+
return key;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Derive a key directly from the raw JWT token string
|
|
55
|
+
*
|
|
56
|
+
* This is useful when you want to derive the key from the token
|
|
57
|
+
* before or without fully parsing the claims. Uses the token's
|
|
58
|
+
* signature portion as additional entropy.
|
|
59
|
+
*
|
|
60
|
+
* @param token - The raw JWT token string
|
|
61
|
+
* @param claims - Parsed JWT claims
|
|
62
|
+
* @returns 32-byte encryption key as Uint8Array
|
|
63
|
+
*/
|
|
64
|
+
async deriveKeyFromToken(token, claims) {
|
|
65
|
+
const parts = token.split(".");
|
|
66
|
+
const signature = parts[2] ?? "";
|
|
67
|
+
const ikmParts = [
|
|
68
|
+
claims.jti,
|
|
69
|
+
claims.vaultKey ?? "",
|
|
70
|
+
claims.sub,
|
|
71
|
+
claims.iat.toString(),
|
|
72
|
+
signature,
|
|
73
|
+
new TextDecoder().decode(this.pepper)
|
|
74
|
+
];
|
|
75
|
+
const ikm = new TextEncoder().encode(ikmParts.join(""));
|
|
76
|
+
const infoBytes = new TextEncoder().encode(this.hkdfInfo);
|
|
77
|
+
const key = await hkdfSha256(ikm, new Uint8Array(0), infoBytes, 32);
|
|
78
|
+
return key;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Encrypt plaintext data using AES-256-GCM
|
|
82
|
+
*
|
|
83
|
+
* @param plaintext - Data to encrypt (typically JSON string)
|
|
84
|
+
* @param key - 32-byte encryption key from deriveKey()
|
|
85
|
+
* @returns Encrypted data object (safe to store in Redis)
|
|
86
|
+
*/
|
|
87
|
+
async encrypt(plaintext, key) {
|
|
88
|
+
if (key.length !== 32) {
|
|
89
|
+
throw new Error("Encryption key must be 32 bytes");
|
|
90
|
+
}
|
|
91
|
+
const iv = randomBytes(12);
|
|
92
|
+
const { ciphertext, tag } = await encryptAesGcm(key, new TextEncoder().encode(plaintext), iv);
|
|
93
|
+
return {
|
|
94
|
+
v: 1,
|
|
95
|
+
alg: "aes-256-gcm",
|
|
96
|
+
iv: base64urlEncode(iv),
|
|
97
|
+
ct: base64urlEncode(ciphertext),
|
|
98
|
+
tag: base64urlEncode(tag)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Decrypt encrypted data using AES-256-GCM
|
|
103
|
+
*
|
|
104
|
+
* @param encrypted - Encrypted data object from encrypt()
|
|
105
|
+
* @param key - 32-byte encryption key from deriveKey()
|
|
106
|
+
* @returns Decrypted plaintext
|
|
107
|
+
* @throws Error if decryption fails (wrong key, tampered data, etc.)
|
|
108
|
+
*/
|
|
109
|
+
async decrypt(encrypted, key) {
|
|
110
|
+
if (key.length !== 32) {
|
|
111
|
+
throw new Error("Encryption key must be 32 bytes");
|
|
112
|
+
}
|
|
113
|
+
const parsed = encryptedDataSchema.safeParse(encrypted);
|
|
114
|
+
if (!parsed.success) {
|
|
115
|
+
throw new Error("Invalid encrypted data format");
|
|
116
|
+
}
|
|
117
|
+
const { iv, ct, tag } = parsed.data;
|
|
118
|
+
const ivBuffer = base64urlDecode(iv);
|
|
119
|
+
const ciphertext = base64urlDecode(ct);
|
|
120
|
+
const tagBuffer = base64urlDecode(tag);
|
|
121
|
+
try {
|
|
122
|
+
const plaintext = await decryptAesGcm(key, ciphertext, ivBuffer, tagBuffer);
|
|
123
|
+
return new TextDecoder().decode(plaintext);
|
|
124
|
+
} catch (_error) {
|
|
125
|
+
throw new Error("Decryption failed: invalid key or corrupted data");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Encrypt a JavaScript object (serializes to JSON first)
|
|
130
|
+
*
|
|
131
|
+
* @param data - Object to encrypt
|
|
132
|
+
* @param key - Encryption key
|
|
133
|
+
* @returns Encrypted data
|
|
134
|
+
*/
|
|
135
|
+
async encryptObject(data, key) {
|
|
136
|
+
return this.encrypt(JSON.stringify(data), key);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Decrypt and parse a JavaScript object
|
|
140
|
+
*
|
|
141
|
+
* @param encrypted - Encrypted data
|
|
142
|
+
* @param key - Encryption key
|
|
143
|
+
* @returns Decrypted and parsed object
|
|
144
|
+
*/
|
|
145
|
+
async decryptObject(encrypted, key) {
|
|
146
|
+
const plaintext = await this.decrypt(encrypted, key);
|
|
147
|
+
return JSON.parse(plaintext);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check if data is in encrypted format
|
|
151
|
+
*
|
|
152
|
+
* @param data - Data to check
|
|
153
|
+
* @returns True if data appears to be encrypted
|
|
154
|
+
*/
|
|
155
|
+
isEncrypted(data) {
|
|
156
|
+
return encryptedDataSchema.safeParse(data).success;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
var encryptedVaultEntrySchema = z.object({
|
|
160
|
+
/** Vault ID (maps to JWT jti claim) */
|
|
161
|
+
id: z.string(),
|
|
162
|
+
/** User subject identifier */
|
|
163
|
+
userSub: z.string(),
|
|
164
|
+
/** User email (unencrypted for display) */
|
|
165
|
+
userEmail: z.string().optional(),
|
|
166
|
+
/** User name (unencrypted for display) */
|
|
167
|
+
userName: z.string().optional(),
|
|
168
|
+
/** Client ID that created this session */
|
|
169
|
+
clientId: z.string(),
|
|
170
|
+
/** Creation timestamp */
|
|
171
|
+
createdAt: z.number(),
|
|
172
|
+
/** Last access timestamp */
|
|
173
|
+
lastAccessAt: z.number(),
|
|
174
|
+
/** Encrypted sensitive data (provider tokens, credentials, consent) */
|
|
175
|
+
encryptedData: encryptedDataSchema,
|
|
176
|
+
/** Apps that are fully authorized (unencrypted for quick lookup) */
|
|
177
|
+
authorizedAppIds: z.array(z.string()),
|
|
178
|
+
/** Apps that were skipped (unencrypted for quick lookup) */
|
|
179
|
+
skippedAppIds: z.array(z.string()),
|
|
180
|
+
/** Pending auth IDs (unencrypted for lookup, actual URLs encrypted) */
|
|
181
|
+
pendingAuthIds: z.array(z.string()).default([])
|
|
182
|
+
});
|
|
183
|
+
export {
|
|
184
|
+
VaultEncryption,
|
|
185
|
+
encryptedDataSchema,
|
|
186
|
+
encryptedVaultEntrySchema
|
|
187
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// libs/auth/src/session/vercel-kv-session.store.ts
|
|
2
|
+
import { randomUUID, VercelKvStorageAdapter } from "@frontmcp/utils";
|
|
3
|
+
import { SessionIdEmptyError } from "../errors/auth-internal.errors.mjs";
|
|
4
|
+
import { signSession, verifyOrParseSession } from "./session-crypto.mjs";
|
|
5
|
+
import { SessionRateLimiter } from "./session-rate-limiter.mjs";
|
|
6
|
+
import {
|
|
7
|
+
storedSessionSchema
|
|
8
|
+
} from "./transport-session.types.mjs";
|
|
9
|
+
var VercelKvSessionStore = class {
|
|
10
|
+
storage;
|
|
11
|
+
keyPrefix;
|
|
12
|
+
defaultTtlMs;
|
|
13
|
+
logger;
|
|
14
|
+
// Security features
|
|
15
|
+
security;
|
|
16
|
+
rateLimiter;
|
|
17
|
+
constructor(config, logger) {
|
|
18
|
+
this.keyPrefix = config.keyPrefix ?? "mcp:session:";
|
|
19
|
+
this.defaultTtlMs = config.defaultTtlMs ?? 36e5;
|
|
20
|
+
this.logger = logger;
|
|
21
|
+
this.security = ("security" in config ? config.security : void 0) ?? {};
|
|
22
|
+
if (this.security.enableRateLimiting) {
|
|
23
|
+
this.rateLimiter = new SessionRateLimiter({
|
|
24
|
+
windowMs: this.security.rateLimiting?.windowMs,
|
|
25
|
+
maxRequests: this.security.rateLimiting?.maxRequests
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
this.storage = new VercelKvStorageAdapter({
|
|
29
|
+
url: config.url,
|
|
30
|
+
token: config.token,
|
|
31
|
+
keyPrefix: this.keyPrefix
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate session ID
|
|
36
|
+
* @throws Error if sessionId is empty
|
|
37
|
+
*/
|
|
38
|
+
validateSessionId(sessionId) {
|
|
39
|
+
if (!sessionId || sessionId.trim() === "") {
|
|
40
|
+
throw new SessionIdEmptyError("VercelKvSessionStore");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Connect to Vercel KV
|
|
45
|
+
* Thread-safe: concurrent calls will share the same connection via adapter.
|
|
46
|
+
*/
|
|
47
|
+
async connect() {
|
|
48
|
+
await this.storage.connect();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ensure the storage adapter is connected
|
|
52
|
+
*/
|
|
53
|
+
async ensureConnected() {
|
|
54
|
+
await this.storage.connect();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get a stored session by ID
|
|
58
|
+
*
|
|
59
|
+
* Note: Vercel KV doesn't support GETEX, so we use GET + EXPIRE separately.
|
|
60
|
+
* This is slightly less atomic than Redis GETEX but sufficient for most use cases.
|
|
61
|
+
*
|
|
62
|
+
* @param sessionId - The session ID to look up
|
|
63
|
+
* @param options - Optional parameters for rate limiting
|
|
64
|
+
* @param options.clientIdentifier - Client identifier (e.g., IP address) for rate limiting.
|
|
65
|
+
* When provided, rate limiting is applied per-client to prevent session enumeration.
|
|
66
|
+
* If not provided, falls back to sessionId which provides DoS protection per-session.
|
|
67
|
+
*/
|
|
68
|
+
async get(sessionId, options) {
|
|
69
|
+
this.validateSessionId(sessionId);
|
|
70
|
+
if (this.rateLimiter) {
|
|
71
|
+
const rateLimitKey = options?.clientIdentifier || sessionId;
|
|
72
|
+
const rateLimitResult = this.rateLimiter.check(rateLimitKey);
|
|
73
|
+
if (!rateLimitResult.allowed) {
|
|
74
|
+
this.logger?.warn("[VercelKvSessionStore] Rate limit exceeded for session lookup", {
|
|
75
|
+
sessionId: sessionId.slice(0, 20),
|
|
76
|
+
clientIdentifier: options?.clientIdentifier ? options.clientIdentifier.slice(0, 20) : void 0,
|
|
77
|
+
retryAfterMs: rateLimitResult.retryAfterMs
|
|
78
|
+
});
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await this.ensureConnected();
|
|
83
|
+
const raw = await this.storage.get(sessionId);
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
const ttlSeconds = Math.ceil(this.defaultTtlMs / 1e3);
|
|
86
|
+
this.storage.expire(sessionId, ttlSeconds).catch((err) => {
|
|
87
|
+
this.logger?.warn("[VercelKvSessionStore] TTL extension failed", {
|
|
88
|
+
sessionId: sessionId.slice(0, 20),
|
|
89
|
+
error: err.message
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
let parsed;
|
|
94
|
+
if (this.security.enableSigning) {
|
|
95
|
+
parsed = verifyOrParseSession(raw, { secret: this.security.signingSecret });
|
|
96
|
+
if (!parsed) {
|
|
97
|
+
this.logger?.warn("[VercelKvSessionStore] Session signature verification failed", {
|
|
98
|
+
sessionId: sessionId.slice(0, 20)
|
|
99
|
+
});
|
|
100
|
+
this.delete(sessionId).catch(() => void 0);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
parsed = JSON.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
const result = storedSessionSchema.safeParse(parsed);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
this.logger?.warn("[VercelKvSessionStore] Invalid session format", {
|
|
109
|
+
sessionId: sessionId.slice(0, 20),
|
|
110
|
+
errors: result.error.issues.slice(0, 3).map((i) => ({ path: i.path, message: i.message }))
|
|
111
|
+
});
|
|
112
|
+
this.delete(sessionId).catch(() => void 0);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const session = result.data;
|
|
116
|
+
if (session.maxLifetimeAt && session.maxLifetimeAt < Date.now()) {
|
|
117
|
+
this.logger?.info("[VercelKvSessionStore] Session exceeded max lifetime", {
|
|
118
|
+
sessionId: sessionId.slice(0, 20),
|
|
119
|
+
maxLifetimeAt: session.maxLifetimeAt
|
|
120
|
+
});
|
|
121
|
+
await this.delete(sessionId);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (session.session.expiresAt && session.session.expiresAt < Date.now()) {
|
|
125
|
+
await this.delete(sessionId);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (session.session.expiresAt) {
|
|
129
|
+
const ttlMs = Math.min(this.defaultTtlMs, session.session.expiresAt - Date.now());
|
|
130
|
+
if (ttlMs > 0 && ttlMs < this.defaultTtlMs) {
|
|
131
|
+
const boundedTtlSeconds = Math.ceil(ttlMs / 1e3);
|
|
132
|
+
this.storage.expire(sessionId, boundedTtlSeconds).catch((err) => {
|
|
133
|
+
this.logger?.warn("[VercelKvSessionStore] TTL bound extension failed", {
|
|
134
|
+
sessionId: sessionId.slice(0, 20),
|
|
135
|
+
error: err.message
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const updatedSession = {
|
|
141
|
+
...session,
|
|
142
|
+
lastAccessedAt: Date.now()
|
|
143
|
+
};
|
|
144
|
+
return updatedSession;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.logger?.warn("[VercelKvSessionStore] Failed to parse session", {
|
|
147
|
+
sessionId: sessionId.slice(0, 20),
|
|
148
|
+
error: error.message
|
|
149
|
+
});
|
|
150
|
+
this.delete(sessionId).catch(() => void 0);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Store a session with optional TTL
|
|
156
|
+
*/
|
|
157
|
+
async set(sessionId, session, ttlMs) {
|
|
158
|
+
this.validateSessionId(sessionId);
|
|
159
|
+
await this.ensureConnected();
|
|
160
|
+
let value;
|
|
161
|
+
if (this.security.enableSigning) {
|
|
162
|
+
value = signSession(session, { secret: this.security.signingSecret });
|
|
163
|
+
} else {
|
|
164
|
+
value = JSON.stringify(session);
|
|
165
|
+
}
|
|
166
|
+
if (ttlMs && ttlMs > 0) {
|
|
167
|
+
const ttlSeconds = Math.ceil(ttlMs / 1e3);
|
|
168
|
+
await this.storage.set(sessionId, value, { ttlSeconds });
|
|
169
|
+
} else if (session.session.expiresAt) {
|
|
170
|
+
const ttl = session.session.expiresAt - Date.now();
|
|
171
|
+
if (ttl > 0) {
|
|
172
|
+
const ttlSeconds = Math.ceil(ttl / 1e3);
|
|
173
|
+
await this.storage.set(sessionId, value, { ttlSeconds });
|
|
174
|
+
} else {
|
|
175
|
+
await this.storage.set(sessionId, value);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
await this.storage.set(sessionId, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Delete a session
|
|
183
|
+
*/
|
|
184
|
+
async delete(sessionId) {
|
|
185
|
+
this.validateSessionId(sessionId);
|
|
186
|
+
await this.ensureConnected();
|
|
187
|
+
await this.storage.delete(sessionId);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Check if a session exists
|
|
191
|
+
*/
|
|
192
|
+
async exists(sessionId) {
|
|
193
|
+
this.validateSessionId(sessionId);
|
|
194
|
+
await this.ensureConnected();
|
|
195
|
+
return this.storage.exists(sessionId);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Allocate a new session ID
|
|
199
|
+
*/
|
|
200
|
+
allocId() {
|
|
201
|
+
return randomUUID();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Disconnect from Vercel KV
|
|
205
|
+
* Vercel KV uses REST API, so this just clears internal state
|
|
206
|
+
*/
|
|
207
|
+
async disconnect() {
|
|
208
|
+
await this.storage.disconnect();
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Test Vercel KV connection by checking if we can access the API.
|
|
212
|
+
* Useful for validating connection on startup.
|
|
213
|
+
*
|
|
214
|
+
* @returns true if connection is healthy, false otherwise
|
|
215
|
+
*/
|
|
216
|
+
async ping() {
|
|
217
|
+
try {
|
|
218
|
+
await this.ensureConnected();
|
|
219
|
+
return this.storage.ping();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.logger?.error("[VercelKvSessionStore] Connection failed", {
|
|
222
|
+
error: error.message
|
|
223
|
+
});
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
export {
|
|
229
|
+
VercelKvSessionStore
|
|
230
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// libs/auth/src/shared-server/index.ts
|
|
2
|
+
import {
|
|
3
|
+
createSharedServerNonce,
|
|
4
|
+
createSharedServerSecret,
|
|
5
|
+
deriveSharedServerKeys,
|
|
6
|
+
isSharedServerNonce,
|
|
7
|
+
proveSharedServerIdentity,
|
|
8
|
+
verifySharedServerIdentity
|
|
9
|
+
} from "./shared-server-identity.mjs";
|
|
10
|
+
export {
|
|
11
|
+
createSharedServerNonce,
|
|
12
|
+
createSharedServerSecret,
|
|
13
|
+
deriveSharedServerKeys,
|
|
14
|
+
isSharedServerNonce,
|
|
15
|
+
proveSharedServerIdentity,
|
|
16
|
+
verifySharedServerIdentity
|
|
17
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// libs/auth/src/shared-server/shared-server-identity.ts
|
|
2
|
+
import {
|
|
3
|
+
base64urlDecode,
|
|
4
|
+
base64urlEncode,
|
|
5
|
+
hkdfSha256,
|
|
6
|
+
hmacSha256,
|
|
7
|
+
randomBytes,
|
|
8
|
+
timingSafeEqual
|
|
9
|
+
} from "@frontmcp/utils";
|
|
10
|
+
var SECRET_BYTES = 32;
|
|
11
|
+
var BASE64URL_32_BYTES = /^[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
var KEY_SALT = "frontmcp shared server";
|
|
13
|
+
var PROOF_LABEL = "frontmcp shared server identity v1";
|
|
14
|
+
var encoder = new TextEncoder();
|
|
15
|
+
function createSharedServerSecret() {
|
|
16
|
+
return base64urlEncode(randomBytes(SECRET_BYTES));
|
|
17
|
+
}
|
|
18
|
+
function createSharedServerNonce() {
|
|
19
|
+
return base64urlEncode(randomBytes(SECRET_BYTES));
|
|
20
|
+
}
|
|
21
|
+
function isBase64url32Bytes(value) {
|
|
22
|
+
return typeof value === "string" && BASE64URL_32_BYTES.test(value);
|
|
23
|
+
}
|
|
24
|
+
function isSharedServerNonce(value) {
|
|
25
|
+
return isBase64url32Bytes(value);
|
|
26
|
+
}
|
|
27
|
+
function deriveSharedServerKeys(secret) {
|
|
28
|
+
if (!isBase64url32Bytes(secret)) throw new TypeError("a shared server secret is 32 bytes of base64url");
|
|
29
|
+
const bytes = base64urlDecode(secret);
|
|
30
|
+
const salt = encoder.encode(KEY_SALT);
|
|
31
|
+
return {
|
|
32
|
+
proofKey: hkdfSha256(bytes, salt, encoder.encode("proof"), 32),
|
|
33
|
+
token: base64urlEncode(hkdfSha256(bytes, salt, encoder.encode("token"), 32))
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function proofBytes(proofKey, { endpoint, nonce, version }) {
|
|
37
|
+
return hmacSha256(proofKey, encoder.encode(JSON.stringify([PROOF_LABEL, endpoint, nonce, version])));
|
|
38
|
+
}
|
|
39
|
+
function proveSharedServerIdentity(proofKey, statement) {
|
|
40
|
+
return base64urlEncode(proofBytes(proofKey, statement));
|
|
41
|
+
}
|
|
42
|
+
function verifySharedServerIdentity(proofKey, statement, proof) {
|
|
43
|
+
if (!isBase64url32Bytes(proof)) return false;
|
|
44
|
+
const expected = proofBytes(proofKey, statement);
|
|
45
|
+
const presented = base64urlDecode(proof);
|
|
46
|
+
return presented.length === expected.length && timingSafeEqual(presented, expected);
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
createSharedServerNonce,
|
|
50
|
+
createSharedServerSecret,
|
|
51
|
+
deriveSharedServerKeys,
|
|
52
|
+
isSharedServerNonce,
|
|
53
|
+
proveSharedServerIdentity,
|
|
54
|
+
verifySharedServerIdentity
|
|
55
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// libs/auth/src/ui/base-layout.ts
|
|
2
|
+
import { escapeHtml } from "@frontmcp/utils";
|
|
3
|
+
import { escapeHtml as escapeHtml2 } from "@frontmcp/utils";
|
|
4
|
+
var CDN = {
|
|
5
|
+
/** Tailwind CSS v4 Browser CDN - generates styles on-the-fly with @theme support */
|
|
6
|
+
tailwind: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
|
|
7
|
+
/** Google Fonts - Inter for modern UI */
|
|
8
|
+
fonts: {
|
|
9
|
+
preconnect: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
|
10
|
+
stylesheet: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var BUILT_IN_AUTH_PAGE_CSP = [
|
|
14
|
+
"default-src 'self'",
|
|
15
|
+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
|
16
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
17
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
18
|
+
"img-src 'self' data: https:",
|
|
19
|
+
"connect-src 'self'",
|
|
20
|
+
"object-src 'none'",
|
|
21
|
+
"base-uri 'none'",
|
|
22
|
+
"frame-ancestors 'none'"
|
|
23
|
+
].join("; ");
|
|
24
|
+
function builtInAuthPageHeaders(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
...options.csp === false ? {} : { "Content-Security-Policy": BUILT_IN_AUTH_PAGE_CSP },
|
|
27
|
+
"X-Frame-Options": "DENY",
|
|
28
|
+
"X-Content-Type-Options": "nosniff",
|
|
29
|
+
"Referrer-Policy": "same-origin",
|
|
30
|
+
"Cache-Control": "no-store"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
var DEFAULT_THEME = {
|
|
34
|
+
colors: {
|
|
35
|
+
primary: "#3b82f6",
|
|
36
|
+
// blue-500
|
|
37
|
+
"primary-dark": "#2563eb",
|
|
38
|
+
// blue-600
|
|
39
|
+
secondary: "#8b5cf6",
|
|
40
|
+
// violet-500
|
|
41
|
+
accent: "#06b6d4",
|
|
42
|
+
// cyan-500
|
|
43
|
+
success: "#22c55e",
|
|
44
|
+
// green-500
|
|
45
|
+
warning: "#f59e0b",
|
|
46
|
+
// amber-500
|
|
47
|
+
danger: "#ef4444"
|
|
48
|
+
// red-500
|
|
49
|
+
},
|
|
50
|
+
fonts: {
|
|
51
|
+
sans: 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
function buildThemeCss(theme) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
if (theme.colors) {
|
|
57
|
+
for (const [key, value] of Object.entries(theme.colors)) {
|
|
58
|
+
if (value) {
|
|
59
|
+
lines.push(`--color-${key}: ${value};`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (theme.fonts) {
|
|
64
|
+
for (const [key, value] of Object.entries(theme.fonts)) {
|
|
65
|
+
if (value) {
|
|
66
|
+
lines.push(`--font-${key}: ${value};`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (theme.customVars) {
|
|
71
|
+
lines.push(theme.customVars);
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n ");
|
|
74
|
+
}
|
|
75
|
+
function baseLayout(content, options) {
|
|
76
|
+
const {
|
|
77
|
+
title,
|
|
78
|
+
description,
|
|
79
|
+
includeTailwind = true,
|
|
80
|
+
includeFonts = true,
|
|
81
|
+
headExtra = "",
|
|
82
|
+
bodyClass = "bg-gray-50 min-h-screen font-sans antialiased",
|
|
83
|
+
theme
|
|
84
|
+
} = options;
|
|
85
|
+
const mergedTheme = {
|
|
86
|
+
colors: { ...DEFAULT_THEME.colors, ...theme?.colors },
|
|
87
|
+
fonts: { ...DEFAULT_THEME.fonts, ...theme?.fonts },
|
|
88
|
+
customVars: theme?.customVars,
|
|
89
|
+
customCss: theme?.customCss
|
|
90
|
+
};
|
|
91
|
+
const fontPreconnect = includeFonts ? CDN.fonts.preconnect.map((url, i) => `<link rel="preconnect" href="${url}"${i > 0 ? " crossorigin" : ""}>`).join("\n ") : "";
|
|
92
|
+
const fontStylesheet = includeFonts ? `<link href="${CDN.fonts.stylesheet}" rel="stylesheet">` : "";
|
|
93
|
+
const themeCss = buildThemeCss(mergedTheme);
|
|
94
|
+
const customCss = mergedTheme.customCss || "";
|
|
95
|
+
const tailwindBlock = includeTailwind ? `<script src="${CDN.tailwind}"></script>
|
|
96
|
+
<style type="text/tailwindcss">
|
|
97
|
+
@theme {
|
|
98
|
+
${themeCss}
|
|
99
|
+
}
|
|
100
|
+
${customCss}
|
|
101
|
+
</style>` : "";
|
|
102
|
+
const metaDescription = description ? `<meta name="description" content="${escapeHtml(description)}">` : "";
|
|
103
|
+
return `<!DOCTYPE html>
|
|
104
|
+
<html lang="en">
|
|
105
|
+
<head>
|
|
106
|
+
<meta charset="UTF-8">
|
|
107
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
108
|
+
<title>${escapeHtml(title)} - FrontMCP</title>
|
|
109
|
+
${metaDescription}
|
|
110
|
+
|
|
111
|
+
<!-- Google Fonts CDN - Inter (modern UI font) -->
|
|
112
|
+
${fontPreconnect}
|
|
113
|
+
${fontStylesheet}
|
|
114
|
+
|
|
115
|
+
<!-- Tailwind CSS v4 Browser CDN with @theme support -->
|
|
116
|
+
${tailwindBlock}
|
|
117
|
+
${headExtra}
|
|
118
|
+
</head>
|
|
119
|
+
<body class="${escapeHtml(bodyClass)}">
|
|
120
|
+
${content}
|
|
121
|
+
</body>
|
|
122
|
+
</html>`;
|
|
123
|
+
}
|
|
124
|
+
function createLayout(defaultOptions) {
|
|
125
|
+
return (content, options) => {
|
|
126
|
+
const mergedTheme = defaultOptions.theme || options.theme ? {
|
|
127
|
+
colors: { ...defaultOptions.theme?.colors, ...options.theme?.colors },
|
|
128
|
+
fonts: { ...defaultOptions.theme?.fonts, ...options.theme?.fonts },
|
|
129
|
+
customVars: options.theme?.customVars ?? defaultOptions.theme?.customVars,
|
|
130
|
+
customCss: options.theme?.customCss ?? defaultOptions.theme?.customCss
|
|
131
|
+
} : void 0;
|
|
132
|
+
return baseLayout(content, {
|
|
133
|
+
...defaultOptions,
|
|
134
|
+
...options,
|
|
135
|
+
theme: mergedTheme
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
var authLayout = createLayout({
|
|
140
|
+
bodyClass: "bg-gray-50 min-h-screen font-sans antialiased"
|
|
141
|
+
});
|
|
142
|
+
function centeredCardLayout(content, options) {
|
|
143
|
+
const wrappedContent = `
|
|
144
|
+
<div class="min-h-screen flex items-center justify-center p-4">
|
|
145
|
+
<div class="w-full max-w-md">
|
|
146
|
+
${content}
|
|
147
|
+
</div>
|
|
148
|
+
</div>`;
|
|
149
|
+
return baseLayout(wrappedContent, {
|
|
150
|
+
...options,
|
|
151
|
+
bodyClass: "bg-gradient-to-br from-primary to-secondary min-h-screen font-sans antialiased"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function wideLayout(content, options) {
|
|
155
|
+
const wrappedContent = `
|
|
156
|
+
<div class="min-h-screen py-8 px-4">
|
|
157
|
+
<div class="max-w-2xl mx-auto">
|
|
158
|
+
${content}
|
|
159
|
+
</div>
|
|
160
|
+
</div>`;
|
|
161
|
+
return baseLayout(wrappedContent, options);
|
|
162
|
+
}
|
|
163
|
+
function extraWideLayout(content, options) {
|
|
164
|
+
const wrappedContent = `
|
|
165
|
+
<div class="min-h-screen py-8 px-4">
|
|
166
|
+
<div class="max-w-3xl mx-auto">
|
|
167
|
+
${content}
|
|
168
|
+
</div>
|
|
169
|
+
</div>`;
|
|
170
|
+
return baseLayout(wrappedContent, options);
|
|
171
|
+
}
|
|
172
|
+
export {
|
|
173
|
+
BUILT_IN_AUTH_PAGE_CSP,
|
|
174
|
+
CDN,
|
|
175
|
+
DEFAULT_THEME,
|
|
176
|
+
authLayout,
|
|
177
|
+
baseLayout,
|
|
178
|
+
builtInAuthPageHeaders,
|
|
179
|
+
centeredCardLayout,
|
|
180
|
+
createLayout,
|
|
181
|
+
escapeHtml2 as escapeHtml,
|
|
182
|
+
extraWideLayout,
|
|
183
|
+
wideLayout
|
|
184
|
+
};
|