@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,376 @@
|
|
|
1
|
+
// libs/auth/src/cimd/cimd.service.ts
|
|
2
|
+
import { isRedirectResponse } from "@frontmcp/utils";
|
|
3
|
+
import { createCimdCache } from "./cimd.cache.mjs";
|
|
4
|
+
import {
|
|
5
|
+
CimdClientIdMismatchError,
|
|
6
|
+
CimdFetchError,
|
|
7
|
+
CimdResponseTooLargeError,
|
|
8
|
+
CimdSecurityError,
|
|
9
|
+
CimdValidationError,
|
|
10
|
+
RedirectUriMismatchError
|
|
11
|
+
} from "./cimd.errors.mjs";
|
|
12
|
+
import { noopLogger } from "./cimd.logger.mjs";
|
|
13
|
+
import {
|
|
14
|
+
cimdCacheConfigSchema,
|
|
15
|
+
cimdConfigSchema,
|
|
16
|
+
cimdNetworkConfigSchema,
|
|
17
|
+
cimdSecurityConfigSchema,
|
|
18
|
+
clientMetadataDocumentSchema
|
|
19
|
+
} from "./cimd.types.mjs";
|
|
20
|
+
import { assertHostNotSsrf, hasOnlyLocalhostRedirectUris, isCimdClientId, validateClientIdUrl } from "./cimd.validator.mjs";
|
|
21
|
+
var CimdService = class {
|
|
22
|
+
config;
|
|
23
|
+
cacheConfig;
|
|
24
|
+
securityConfig;
|
|
25
|
+
networkConfig;
|
|
26
|
+
cacheBackend;
|
|
27
|
+
logger;
|
|
28
|
+
/**
|
|
29
|
+
* Whether CIMD is enabled.
|
|
30
|
+
*/
|
|
31
|
+
get enabled() {
|
|
32
|
+
return this.config.enabled;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a new CIMD service.
|
|
36
|
+
*
|
|
37
|
+
* @param logger - Optional logger. If not provided, logging is disabled.
|
|
38
|
+
* @param config - Optional configuration.
|
|
39
|
+
*/
|
|
40
|
+
constructor(logger, config) {
|
|
41
|
+
this.logger = (logger ?? noopLogger).child("CimdService");
|
|
42
|
+
this.config = cimdConfigSchema.parse(config ?? {});
|
|
43
|
+
this.cacheConfig = cimdCacheConfigSchema.parse(this.config.cache ?? {});
|
|
44
|
+
this.securityConfig = cimdSecurityConfigSchema.parse(this.config.security ?? {});
|
|
45
|
+
this.networkConfig = cimdNetworkConfigSchema.parse(this.config.network ?? {});
|
|
46
|
+
this.logger.debug("CimdService initialized", {
|
|
47
|
+
enabled: this.config.enabled,
|
|
48
|
+
cacheDefaultTtlMs: this.cacheConfig.defaultTtlMs,
|
|
49
|
+
networkTimeoutMs: this.networkConfig.timeoutMs
|
|
50
|
+
});
|
|
51
|
+
if (this.securityConfig.allowInsecureForTesting) {
|
|
52
|
+
this.logger.warn(
|
|
53
|
+
"CIMD allowInsecureForTesting is enabled. HTTP is allowed for localhost CIMD URLs. This should NEVER be enabled in production!"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create the cache now (connecting to Redis with `cache.type: 'redis'`), so a misconfigured or
|
|
59
|
+
* unreachable cache fails at startup rather than on the first CIMD client.
|
|
60
|
+
*/
|
|
61
|
+
async initialize() {
|
|
62
|
+
await this.cache();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The cache `cache.type` selects, created once through the `createCimdCache` factory. A failed
|
|
66
|
+
* creation is retried on the next use.
|
|
67
|
+
*/
|
|
68
|
+
cache() {
|
|
69
|
+
if (!this.cacheBackend) {
|
|
70
|
+
this.cacheBackend = createCimdCache(this.cacheConfig).catch((error) => {
|
|
71
|
+
this.cacheBackend = void 0;
|
|
72
|
+
throw error;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return this.cacheBackend;
|
|
76
|
+
}
|
|
77
|
+
/** Close the cache's connection (Redis with `cache.type: 'redis'`), if one was created. */
|
|
78
|
+
async dispose() {
|
|
79
|
+
const backend = this.cacheBackend;
|
|
80
|
+
this.cacheBackend = void 0;
|
|
81
|
+
if (!backend) return;
|
|
82
|
+
const createdBackend = await backend.catch(() => void 0);
|
|
83
|
+
await createdBackend?.close?.();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Check if a client_id is a CIMD URL.
|
|
87
|
+
*
|
|
88
|
+
* @param clientId - The client_id to check
|
|
89
|
+
* @returns true if this is a CIMD client_id (HTTPS URL with path, or HTTP for localhost when testing)
|
|
90
|
+
*/
|
|
91
|
+
isCimdClientId(clientId) {
|
|
92
|
+
return isCimdClientId(clientId, this.securityConfig.allowInsecureForTesting);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a client_id to its metadata document.
|
|
96
|
+
*
|
|
97
|
+
* If the client_id is a CIMD URL, this fetches and validates the metadata document.
|
|
98
|
+
* Non-CIMD client IDs return a result with isCimdClient: false.
|
|
99
|
+
*
|
|
100
|
+
* @param clientId - The client_id to resolve
|
|
101
|
+
* @returns Resolution result with metadata if available
|
|
102
|
+
*/
|
|
103
|
+
async resolveClientMetadata(clientId) {
|
|
104
|
+
if (!this.isCimdClientId(clientId)) {
|
|
105
|
+
return {
|
|
106
|
+
isCimdClient: false,
|
|
107
|
+
fromCache: false
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
validateClientIdUrl(clientId, this.securityConfig);
|
|
111
|
+
const cache = await this.cache();
|
|
112
|
+
const cached = await cache.get(clientId);
|
|
113
|
+
if (cached) {
|
|
114
|
+
this.logger.debug(`Cache hit for CIMD client: ${clientId}`);
|
|
115
|
+
return {
|
|
116
|
+
isCimdClient: true,
|
|
117
|
+
metadata: cached.document,
|
|
118
|
+
fromCache: true,
|
|
119
|
+
expiresAt: cached.expiresAt,
|
|
120
|
+
etag: cached.etag,
|
|
121
|
+
lastModified: cached.lastModified
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
this.logger.info(`Fetching CIMD document: ${clientId}`);
|
|
125
|
+
const { document, headers } = await this.fetchMetadataDocument(clientId);
|
|
126
|
+
this.validateDocument(clientId, document);
|
|
127
|
+
if (this.securityConfig.warnOnLocalhostRedirects && hasOnlyLocalhostRedirectUris(document.redirect_uris)) {
|
|
128
|
+
this.logger.warn(`CIMD client "${clientId}" has only localhost redirect URIs - this may be a development client`);
|
|
129
|
+
}
|
|
130
|
+
await cache.set(clientId, document, headers);
|
|
131
|
+
const entry = await cache.get(clientId);
|
|
132
|
+
return {
|
|
133
|
+
isCimdClient: true,
|
|
134
|
+
metadata: document,
|
|
135
|
+
fromCache: false,
|
|
136
|
+
expiresAt: entry?.expiresAt,
|
|
137
|
+
etag: entry?.etag,
|
|
138
|
+
lastModified: entry?.lastModified
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Validate that a redirect_uri is registered for the client.
|
|
143
|
+
*
|
|
144
|
+
* @param redirectUri - The redirect_uri from the authorization request
|
|
145
|
+
* @param metadata - The client's metadata document
|
|
146
|
+
* @throws RedirectUriMismatchError if the redirect_uri is not registered
|
|
147
|
+
*/
|
|
148
|
+
validateRedirectUri(redirectUri, metadata) {
|
|
149
|
+
const normalizedRequestUri = normalizeRedirectUri(redirectUri);
|
|
150
|
+
const normalizedAllowed = metadata.redirect_uris.map(normalizeRedirectUri);
|
|
151
|
+
if (!normalizedAllowed.includes(normalizedRequestUri)) {
|
|
152
|
+
throw new RedirectUriMismatchError(metadata.client_id, redirectUri, metadata.redirect_uris);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Clear the cache for a specific client or all clients.
|
|
157
|
+
*
|
|
158
|
+
* @param clientId - Optional client_id to clear; clears all if not provided
|
|
159
|
+
*/
|
|
160
|
+
async clearCache(clientId) {
|
|
161
|
+
const cache = await this.cache();
|
|
162
|
+
if (clientId) {
|
|
163
|
+
await cache.delete(clientId);
|
|
164
|
+
this.logger.debug(`Cache cleared for: ${clientId}`);
|
|
165
|
+
} else {
|
|
166
|
+
await cache.clear();
|
|
167
|
+
this.logger.debug("Cache cleared");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get cache statistics.
|
|
172
|
+
*/
|
|
173
|
+
async getCacheStats() {
|
|
174
|
+
const cache = await this.cache();
|
|
175
|
+
return {
|
|
176
|
+
size: await cache.size()
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fetch a metadata document from a CIMD URL.
|
|
181
|
+
*
|
|
182
|
+
* Following the JwksService.fetchJson() pattern.
|
|
183
|
+
*/
|
|
184
|
+
async fetchMetadataDocument(clientId) {
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
const timer = setTimeout(() => controller.abort(), this.networkConfig.timeoutMs);
|
|
187
|
+
try {
|
|
188
|
+
const cache = await this.cache();
|
|
189
|
+
const conditionalHeaders = await cache.getConditionalHeaders(clientId);
|
|
190
|
+
const originalOrigin = new URL(clientId).origin;
|
|
191
|
+
const maxRedirects = this.networkConfig.maxRedirects;
|
|
192
|
+
let currentUrl = clientId;
|
|
193
|
+
let redirectCount = 0;
|
|
194
|
+
let isFirstRequest = true;
|
|
195
|
+
while (true) {
|
|
196
|
+
if (this.securityConfig.blockPrivateIPs && !this.securityConfig.allowInsecureForTesting) {
|
|
197
|
+
await assertHostNotSsrf(new URL(currentUrl).hostname, clientId);
|
|
198
|
+
}
|
|
199
|
+
const headers = {
|
|
200
|
+
Accept: "application/json"
|
|
201
|
+
};
|
|
202
|
+
if (isFirstRequest && conditionalHeaders) {
|
|
203
|
+
Object.assign(headers, conditionalHeaders);
|
|
204
|
+
}
|
|
205
|
+
const response = await fetch(currentUrl, {
|
|
206
|
+
method: "GET",
|
|
207
|
+
headers,
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
redirect: "manual"
|
|
210
|
+
});
|
|
211
|
+
if (response.status === 304) {
|
|
212
|
+
const staleEntry = await cache.getStale(clientId);
|
|
213
|
+
if (staleEntry) {
|
|
214
|
+
await cache.revalidate(clientId, response.headers);
|
|
215
|
+
this.logger.debug(`CIMD document not modified: ${clientId}`);
|
|
216
|
+
return {
|
|
217
|
+
document: staleEntry.document,
|
|
218
|
+
headers: response.headers
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
throw new CimdFetchError(clientId, "304 Not Modified but no cached entry", {
|
|
222
|
+
httpStatus: 304
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (isRedirectResponse(response)) {
|
|
226
|
+
const location = response.headers.get("location");
|
|
227
|
+
if (!location) {
|
|
228
|
+
throw new CimdFetchError(clientId, "Redirect response missing Location header", {
|
|
229
|
+
httpStatus: response.status
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
233
|
+
const redirectPolicy = this.networkConfig.redirectPolicy;
|
|
234
|
+
if (redirectPolicy === "deny") {
|
|
235
|
+
throw new CimdFetchError(
|
|
236
|
+
clientId,
|
|
237
|
+
`CIMD fetch redirected to "${nextUrl}" but redirects are disabled. Host the metadata at the client_id URL or set auth.cimd.network.redirectPolicy to "same-origin" or "allow".`,
|
|
238
|
+
{ httpStatus: response.status }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (redirectPolicy === "same-origin") {
|
|
242
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
243
|
+
if (nextOrigin !== originalOrigin) {
|
|
244
|
+
throw new CimdFetchError(
|
|
245
|
+
clientId,
|
|
246
|
+
`CIMD fetch redirected to "${nextUrl}" which is not the same origin as "${originalOrigin}". Host the metadata at the client_id URL or set auth.cimd.network.redirectPolicy to "allow".`,
|
|
247
|
+
{ httpStatus: response.status }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
validateClientIdUrl(nextUrl, this.securityConfig);
|
|
252
|
+
redirectCount += 1;
|
|
253
|
+
if (redirectCount > maxRedirects) {
|
|
254
|
+
throw new CimdFetchError(
|
|
255
|
+
clientId,
|
|
256
|
+
`CIMD fetch exceeded max redirects (${maxRedirects}). Host the metadata at the client_id URL or increase auth.cimd.network.maxRedirects.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
this.logger.warn(`CIMD redirect ${redirectCount}/${maxRedirects}: ${currentUrl} -> ${nextUrl}`);
|
|
260
|
+
currentUrl = nextUrl;
|
|
261
|
+
isFirstRequest = false;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new CimdFetchError(clientId, `HTTP ${response.status} ${response.statusText}`, {
|
|
266
|
+
httpStatus: response.status
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const contentLength = response.headers.get("content-length");
|
|
270
|
+
if (contentLength) {
|
|
271
|
+
const length = parseInt(contentLength, 10);
|
|
272
|
+
if (!isNaN(length) && length > this.networkConfig.maxResponseSizeBytes) {
|
|
273
|
+
throw new CimdResponseTooLargeError(clientId, this.networkConfig.maxResponseSizeBytes, length);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const text = await this.readResponseWithLimit(response, this.networkConfig.maxResponseSizeBytes, clientId);
|
|
277
|
+
let document;
|
|
278
|
+
try {
|
|
279
|
+
document = JSON.parse(text);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
throw new CimdFetchError(clientId, "Invalid JSON response", {
|
|
282
|
+
originalError: e instanceof Error ? e : new Error(String(e))
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return { document, headers: response.headers };
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof CimdFetchError || error instanceof CimdResponseTooLargeError || error instanceof CimdSecurityError) {
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
292
|
+
throw new CimdFetchError(clientId, "Request timeout", {
|
|
293
|
+
originalError: error
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
throw new CimdFetchError(clientId, error instanceof Error ? error.message : "Unknown error", {
|
|
297
|
+
originalError: error instanceof Error ? error : void 0
|
|
298
|
+
});
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Read response body with size limit.
|
|
305
|
+
*/
|
|
306
|
+
async readResponseWithLimit(response, maxBytes, clientId) {
|
|
307
|
+
const reader = response.body?.getReader();
|
|
308
|
+
if (!reader) {
|
|
309
|
+
const buffer = await response.arrayBuffer();
|
|
310
|
+
if (buffer.byteLength > maxBytes) {
|
|
311
|
+
throw new CimdResponseTooLargeError(clientId, maxBytes, buffer.byteLength);
|
|
312
|
+
}
|
|
313
|
+
return new TextDecoder().decode(buffer);
|
|
314
|
+
}
|
|
315
|
+
const chunks = [];
|
|
316
|
+
let totalBytes = 0;
|
|
317
|
+
try {
|
|
318
|
+
while (true) {
|
|
319
|
+
const { done, value } = await reader.read();
|
|
320
|
+
if (done) break;
|
|
321
|
+
totalBytes += value.length;
|
|
322
|
+
if (totalBytes > maxBytes) {
|
|
323
|
+
throw new CimdResponseTooLargeError(clientId, maxBytes, totalBytes);
|
|
324
|
+
}
|
|
325
|
+
chunks.push(value);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
reader.releaseLock();
|
|
329
|
+
}
|
|
330
|
+
const combined = new Uint8Array(totalBytes);
|
|
331
|
+
let offset = 0;
|
|
332
|
+
for (const chunk of chunks) {
|
|
333
|
+
combined.set(chunk, offset);
|
|
334
|
+
offset += chunk.length;
|
|
335
|
+
}
|
|
336
|
+
return new TextDecoder().decode(combined);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Validate a fetched document against the schema.
|
|
340
|
+
*
|
|
341
|
+
* @param clientId - The URL from which the document was fetched
|
|
342
|
+
* @param document - The document to validate
|
|
343
|
+
*/
|
|
344
|
+
validateDocument(clientId, document) {
|
|
345
|
+
const result = clientMetadataDocumentSchema.safeParse(document);
|
|
346
|
+
if (!result.success) {
|
|
347
|
+
const errors = result.error.issues.map((issue) => {
|
|
348
|
+
const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
349
|
+
return `${path}${issue.message}`;
|
|
350
|
+
});
|
|
351
|
+
throw new CimdValidationError(clientId, errors);
|
|
352
|
+
}
|
|
353
|
+
if (result.data.client_id !== clientId) {
|
|
354
|
+
throw new CimdClientIdMismatchError(clientId, result.data.client_id);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
359
|
+
function isLoopbackHostname(hostname) {
|
|
360
|
+
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
|
|
361
|
+
}
|
|
362
|
+
function normalizeRedirectUri(uri) {
|
|
363
|
+
try {
|
|
364
|
+
const url = new URL(uri);
|
|
365
|
+
const host = isLoopbackHostname(url.hostname) ? url.hostname.toLowerCase() : url.host.toLowerCase();
|
|
366
|
+
let normalized = `${url.protocol.toLowerCase()}//${host}`;
|
|
367
|
+
normalized += url.pathname.replace(/\/+$/, "") || "/";
|
|
368
|
+
if (url.search) normalized += url.search;
|
|
369
|
+
return normalized;
|
|
370
|
+
} catch {
|
|
371
|
+
return uri;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
export {
|
|
375
|
+
CimdService
|
|
376
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// libs/auth/src/cimd/cimd.types.ts
|
|
2
|
+
import { internalZ as z } from "@frontmcp/lazy-zod";
|
|
3
|
+
var clientMetadataDocumentSchema = z.object({
|
|
4
|
+
// REQUIRED per CIMD spec
|
|
5
|
+
/**
|
|
6
|
+
* Client identifier - MUST match the URL from which this document was fetched.
|
|
7
|
+
*/
|
|
8
|
+
client_id: z.string().url(),
|
|
9
|
+
/**
|
|
10
|
+
* Human-readable name of the client.
|
|
11
|
+
*/
|
|
12
|
+
client_name: z.string().min(1),
|
|
13
|
+
/**
|
|
14
|
+
* Array of redirect URIs for authorization responses.
|
|
15
|
+
* At least one is required.
|
|
16
|
+
*/
|
|
17
|
+
redirect_uris: z.array(z.string().url()).min(1),
|
|
18
|
+
// OPTIONAL per RFC 7591
|
|
19
|
+
/**
|
|
20
|
+
* Token endpoint authentication method.
|
|
21
|
+
* @default 'none'
|
|
22
|
+
*/
|
|
23
|
+
token_endpoint_auth_method: z.enum(["none", "client_secret_basic", "client_secret_post", "private_key_jwt"]).default("none"),
|
|
24
|
+
/**
|
|
25
|
+
* OAuth grant types the client can use.
|
|
26
|
+
* @default ['authorization_code']
|
|
27
|
+
*/
|
|
28
|
+
grant_types: z.array(z.string()).default(["authorization_code"]),
|
|
29
|
+
/**
|
|
30
|
+
* OAuth response types the client can request.
|
|
31
|
+
* @default ['code']
|
|
32
|
+
*/
|
|
33
|
+
response_types: z.array(z.string()).default(["code"]),
|
|
34
|
+
/**
|
|
35
|
+
* URL of the client's home page.
|
|
36
|
+
*/
|
|
37
|
+
client_uri: z.string().url().optional(),
|
|
38
|
+
/**
|
|
39
|
+
* URL of the client's logo image.
|
|
40
|
+
*/
|
|
41
|
+
logo_uri: z.string().url().optional(),
|
|
42
|
+
/**
|
|
43
|
+
* URL of the client's JWKS (for private_key_jwt).
|
|
44
|
+
*/
|
|
45
|
+
jwks_uri: z.string().url().optional(),
|
|
46
|
+
/**
|
|
47
|
+
* Inline JWKS (for private_key_jwt).
|
|
48
|
+
*/
|
|
49
|
+
jwks: z.object({
|
|
50
|
+
keys: z.array(z.record(z.string(), z.unknown()))
|
|
51
|
+
}).optional(),
|
|
52
|
+
/**
|
|
53
|
+
* URL of the client's terms of service.
|
|
54
|
+
*/
|
|
55
|
+
tos_uri: z.string().url().optional(),
|
|
56
|
+
/**
|
|
57
|
+
* URL of the client's privacy policy.
|
|
58
|
+
*/
|
|
59
|
+
policy_uri: z.string().url().optional(),
|
|
60
|
+
/**
|
|
61
|
+
* Requested OAuth scopes.
|
|
62
|
+
*/
|
|
63
|
+
scope: z.string().optional(),
|
|
64
|
+
/**
|
|
65
|
+
* Array of contact emails for the client.
|
|
66
|
+
*/
|
|
67
|
+
contacts: z.array(z.string().email()).optional(),
|
|
68
|
+
/**
|
|
69
|
+
* Software statement (signed JWT).
|
|
70
|
+
*/
|
|
71
|
+
software_statement: z.string().optional(),
|
|
72
|
+
/**
|
|
73
|
+
* Unique identifier for the client software.
|
|
74
|
+
*/
|
|
75
|
+
software_id: z.string().optional(),
|
|
76
|
+
/**
|
|
77
|
+
* Version of the client software.
|
|
78
|
+
*/
|
|
79
|
+
software_version: z.string().optional()
|
|
80
|
+
});
|
|
81
|
+
var cimdRedisCacheConfigSchema = z.object({
|
|
82
|
+
/**
|
|
83
|
+
* Redis connection URL.
|
|
84
|
+
* e.g., "redis://user:pass@host:6379/0"
|
|
85
|
+
*/
|
|
86
|
+
url: z.string().optional(),
|
|
87
|
+
/**
|
|
88
|
+
* Redis host.
|
|
89
|
+
*/
|
|
90
|
+
host: z.string().optional(),
|
|
91
|
+
/**
|
|
92
|
+
* Redis port.
|
|
93
|
+
* @default 6379
|
|
94
|
+
*/
|
|
95
|
+
port: z.number().optional(),
|
|
96
|
+
/**
|
|
97
|
+
* Redis password.
|
|
98
|
+
*/
|
|
99
|
+
password: z.string().optional(),
|
|
100
|
+
/**
|
|
101
|
+
* Redis database number.
|
|
102
|
+
* @default 0
|
|
103
|
+
*/
|
|
104
|
+
db: z.number().optional(),
|
|
105
|
+
/**
|
|
106
|
+
* Enable TLS for Redis connection.
|
|
107
|
+
* @default false
|
|
108
|
+
*/
|
|
109
|
+
tls: z.boolean().optional(),
|
|
110
|
+
/**
|
|
111
|
+
* Key prefix for CIMD cache entries.
|
|
112
|
+
* @default 'cimd:'
|
|
113
|
+
*/
|
|
114
|
+
keyPrefix: z.string().default("cimd:")
|
|
115
|
+
});
|
|
116
|
+
var cimdCacheConfigSchema = z.object({
|
|
117
|
+
/**
|
|
118
|
+
* Cache storage type.
|
|
119
|
+
* - 'memory': In-memory cache (default, suitable for dev/single-instance)
|
|
120
|
+
* - 'redis': Redis-backed cache (for production/distributed deployments)
|
|
121
|
+
* @default 'memory'
|
|
122
|
+
*/
|
|
123
|
+
type: z.enum(["memory", "redis"]).default("memory"),
|
|
124
|
+
/**
|
|
125
|
+
* Default TTL for cached metadata documents.
|
|
126
|
+
* @default 3600000 (1 hour)
|
|
127
|
+
*/
|
|
128
|
+
defaultTtlMs: z.number().min(0).default(36e5),
|
|
129
|
+
/**
|
|
130
|
+
* Maximum TTL (even if server suggests longer).
|
|
131
|
+
* @default 86400000 (24 hours)
|
|
132
|
+
*/
|
|
133
|
+
maxTtlMs: z.number().min(0).default(864e5),
|
|
134
|
+
/**
|
|
135
|
+
* Minimum TTL (even if server suggests shorter).
|
|
136
|
+
* @default 60000 (1 minute)
|
|
137
|
+
*/
|
|
138
|
+
minTtlMs: z.number().min(0).default(6e4),
|
|
139
|
+
/**
|
|
140
|
+
* Redis configuration (required when type is 'redis').
|
|
141
|
+
*/
|
|
142
|
+
redis: cimdRedisCacheConfigSchema.optional()
|
|
143
|
+
});
|
|
144
|
+
var cimdSecurityConfigSchema = z.object({
|
|
145
|
+
/**
|
|
146
|
+
* Block fetching from private/internal IP addresses (SSRF protection).
|
|
147
|
+
* @default true
|
|
148
|
+
*/
|
|
149
|
+
blockPrivateIPs: z.boolean().default(true),
|
|
150
|
+
/**
|
|
151
|
+
* Explicit list of allowed domains.
|
|
152
|
+
* If set, only these domains can host CIMD documents.
|
|
153
|
+
*/
|
|
154
|
+
allowedDomains: z.array(z.string()).optional(),
|
|
155
|
+
/**
|
|
156
|
+
* Explicit list of blocked domains.
|
|
157
|
+
* These domains cannot host CIMD documents.
|
|
158
|
+
*/
|
|
159
|
+
blockedDomains: z.array(z.string()).optional(),
|
|
160
|
+
/**
|
|
161
|
+
* Warn when a client has only localhost redirect URIs.
|
|
162
|
+
* @default true
|
|
163
|
+
*/
|
|
164
|
+
warnOnLocalhostRedirects: z.boolean().default(true),
|
|
165
|
+
/**
|
|
166
|
+
* Allow HTTP (instead of HTTPS) for localhost CIMD URLs.
|
|
167
|
+
*
|
|
168
|
+
* **WARNING: This is for testing purposes only. Never enable in production!**
|
|
169
|
+
*
|
|
170
|
+
* When enabled, permits HTTP URLs for localhost CIMD client IDs during testing.
|
|
171
|
+
* The CIMD spec requires HTTPS, but e2e test servers typically use HTTP.
|
|
172
|
+
*
|
|
173
|
+
* @default false
|
|
174
|
+
*/
|
|
175
|
+
allowInsecureForTesting: z.boolean().default(false)
|
|
176
|
+
});
|
|
177
|
+
var cimdNetworkConfigSchema = z.object({
|
|
178
|
+
/**
|
|
179
|
+
* Request timeout in milliseconds.
|
|
180
|
+
* @default 5000 (5 seconds)
|
|
181
|
+
*/
|
|
182
|
+
timeoutMs: z.number().min(100).default(5e3),
|
|
183
|
+
/**
|
|
184
|
+
* Maximum response body size in bytes.
|
|
185
|
+
* @default 65536 (64KB)
|
|
186
|
+
*/
|
|
187
|
+
maxResponseSizeBytes: z.number().min(1024).default(65536),
|
|
188
|
+
/**
|
|
189
|
+
* Redirect handling policy for CIMD fetches.
|
|
190
|
+
* - 'deny': reject redirects (default, safest)
|
|
191
|
+
* - 'same-origin': allow redirects only to the same origin
|
|
192
|
+
* - 'allow': allow redirects to any origin
|
|
193
|
+
* @default 'deny'
|
|
194
|
+
*/
|
|
195
|
+
redirectPolicy: z.enum(["deny", "same-origin", "allow"]).default("deny"),
|
|
196
|
+
/**
|
|
197
|
+
* Maximum number of redirects to follow when redirects are allowed.
|
|
198
|
+
* @default 5
|
|
199
|
+
*/
|
|
200
|
+
maxRedirects: z.number().int().min(0).default(5)
|
|
201
|
+
});
|
|
202
|
+
var cimdConfigSchema = z.object({
|
|
203
|
+
/**
|
|
204
|
+
* Enable CIMD support.
|
|
205
|
+
* @default true
|
|
206
|
+
*/
|
|
207
|
+
enabled: z.boolean().default(true),
|
|
208
|
+
/**
|
|
209
|
+
* Cache configuration.
|
|
210
|
+
*/
|
|
211
|
+
cache: cimdCacheConfigSchema.optional(),
|
|
212
|
+
/**
|
|
213
|
+
* Security configuration.
|
|
214
|
+
*/
|
|
215
|
+
security: cimdSecurityConfigSchema.optional(),
|
|
216
|
+
/**
|
|
217
|
+
* Network configuration.
|
|
218
|
+
*/
|
|
219
|
+
network: cimdNetworkConfigSchema.optional()
|
|
220
|
+
});
|
|
221
|
+
export {
|
|
222
|
+
cimdCacheConfigSchema,
|
|
223
|
+
cimdConfigSchema,
|
|
224
|
+
cimdNetworkConfigSchema,
|
|
225
|
+
cimdRedisCacheConfigSchema,
|
|
226
|
+
cimdSecurityConfigSchema,
|
|
227
|
+
clientMetadataDocumentSchema
|
|
228
|
+
};
|