@astrale-os/sdk 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/verify.d.ts +2 -0
- package/dist/auth/verify.d.ts.map +1 -1
- package/dist/auth/verify.js +81 -26
- package/dist/auth/verify.js.map +1 -1
- package/dist/server/worker-entry.d.ts +17 -0
- package/dist/server/worker-entry.d.ts.map +1 -1
- package/dist/server/worker-entry.js +43 -8
- package/dist/server/worker-entry.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/verify.ts +89 -28
- package/src/server/worker-entry.ts +45 -6
package/dist/auth/verify.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type VerifiedInbound = {
|
|
|
17
17
|
/** Delegation — scoped caller permissions as kernel-signed credential */
|
|
18
18
|
delegation: Delegation;
|
|
19
19
|
};
|
|
20
|
+
/** Clear cached JWKS resolvers. Used in tests when keys rotate between fixtures. */
|
|
21
|
+
export declare function clearJwksCache(): void;
|
|
20
22
|
/**
|
|
21
23
|
* Verify an inbound delegation credential using kernel-core's verification.
|
|
22
24
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/auth/verify.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,UAAU,EAEV,kBAAkB,EACnB,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../src/auth/verify.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,UAAU,EAEV,kBAAkB,EACnB,MAAM,yBAAyB,CAAA;AAYhC,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAKtD,MAAM,MAAM,eAAe,GAAG;IAC5B,2DAA2D;IAC3D,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAA;IACd,2DAA2D;IAC3D,WAAW,EAAE,WAAW,CAAA;IACxB,yEAAyE;IACzE,UAAU,EAAE,UAAU,CAAA;CACvB,CAAA;AAkBD,oFAAoF;AACpF,wBAAgB,cAAc,IAAI,IAAI,CAGrC;AAiFD;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,UAAU,EAAE,eAAe,EAC3B,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,eAAe,CAAC,CA+C1B"}
|
package/dist/auth/verify.js
CHANGED
|
@@ -5,19 +5,68 @@
|
|
|
5
5
|
* (CredentialMethodResolver, MethodRegistry) instead of manual JWT handling.
|
|
6
6
|
* Not tied to JWT — supports any credential method kernel-core provides.
|
|
7
7
|
*/
|
|
8
|
-
import { CredentialMethodResolver, MethodRegistry, verifyAudience, verifyCredential, } from '@astrale-os/kernel-core';
|
|
8
|
+
import { CredentialMethodResolver, MethodRegistry, SignatureVerificationError, SigningKeyNotFoundError, verifyAudience, verifyCredential, } from '@astrale-os/kernel-core';
|
|
9
9
|
import { createLocalJWKSet, createRemoteJWKSet } from 'jose';
|
|
10
10
|
import { derivePublicJwk } from '../server/jwks';
|
|
11
11
|
import { canonicalizeServingUrl } from '../server/serving-url';
|
|
12
12
|
const methodResolver = new CredentialMethodResolver(new MethodRegistry());
|
|
13
|
+
// JWKS resolvers cached per JWKS URL (module-level, like the pools/selfIds
|
|
14
|
+
// Maps in kernel-client.ts). jose handles freshness WITHIN a resolver: 10min
|
|
15
|
+
// max-age, 30s fetch cooldown, and auto-refetch on kid-miss when not cooling
|
|
16
|
+
// down. The one gap — issuer restarts with new keys while the cooldown pins
|
|
17
|
+
// the old set (the incident that got a prior indefinite cache removed) — is
|
|
18
|
+
// covered by evict-and-retry-once in `verifyInboundCredential`, so indefinite
|
|
19
|
+
// Map residency is safe and the per-call JWKS fetch is gone.
|
|
20
|
+
const remoteResolvers = new Map();
|
|
21
|
+
// Self-issued credentials verify against the worker's own in-memory key;
|
|
22
|
+
// cache the local JWKS per canonical self-issuer (one key per worker) so the
|
|
23
|
+
// public-JWK derivation isn't redone on every request.
|
|
24
|
+
const localResolvers = new Map();
|
|
25
|
+
/** Clear cached JWKS resolvers. Used in tests when keys rotate between fixtures. */
|
|
26
|
+
export function clearJwksCache() {
|
|
27
|
+
remoteResolvers.clear();
|
|
28
|
+
localResolvers.clear();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* M-28 fix: a bare-slug iss (`mails.localhost`) makes
|
|
32
|
+
* `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid URL
|
|
33
|
+
* string". The dispatcher's identity map normalizes the iss it signs with,
|
|
34
|
+
* but inbound creds from older clients may still carry the slug form. Coerce
|
|
35
|
+
* to a URL with a default `https://` scheme (matches kernel.astrale.ai
|
|
36
|
+
* canonical form). If the actual receiver is on http://localhost the
|
|
37
|
+
* receiver-side resolver still works because both endpoints are on localhost
|
|
38
|
+
* — but for prod targets requiring TLS this is the right default.
|
|
39
|
+
*/
|
|
40
|
+
function jwksUrlFor(issuer) {
|
|
41
|
+
const normalized = /^https?:\/\//.test(issuer) ? issuer : `https://${issuer}`;
|
|
42
|
+
return `${normalized}/.well-known/jwks.json`;
|
|
43
|
+
}
|
|
44
|
+
function getRemoteResolver(jwksUrl) {
|
|
45
|
+
let resolver = remoteResolvers.get(jwksUrl);
|
|
46
|
+
if (!resolver) {
|
|
47
|
+
resolver = createRemoteJWKSet(new URL(jwksUrl));
|
|
48
|
+
remoteResolvers.set(jwksUrl, resolver);
|
|
49
|
+
}
|
|
50
|
+
return resolver;
|
|
51
|
+
}
|
|
52
|
+
function getLocalResolver(selfIssuer, privateKey) {
|
|
53
|
+
let resolver = localResolvers.get(selfIssuer);
|
|
54
|
+
if (!resolver) {
|
|
55
|
+
resolver = createLocalJWKSet({ keys: [derivePublicJwk(privateKey)] });
|
|
56
|
+
localResolvers.set(selfIssuer, resolver);
|
|
57
|
+
}
|
|
58
|
+
return resolver;
|
|
59
|
+
}
|
|
13
60
|
/**
|
|
14
61
|
* Build the key resolver for one verifying server. Captures `config` so it can
|
|
15
62
|
* short-circuit the server's OWN issuer (`config.issuer`): a self-issued
|
|
16
63
|
* credential is verified against the in-memory public key, never fetched — a
|
|
17
64
|
* Worker can't fetch its own hostname, and it already holds the key. Every other
|
|
18
|
-
* issuer is resolved
|
|
65
|
+
* issuer is resolved via the cached per-URL JWKS resolvers above; every JWKS
|
|
66
|
+
* URL touched is recorded in `resolvedJwksUrls` so the caller can evict
|
|
67
|
+
* exactly those resolvers if verification fails on an unknown signing key.
|
|
19
68
|
*/
|
|
20
|
-
function makeResolveKeys(config) {
|
|
69
|
+
function makeResolveKeys(config, resolvedJwksUrls) {
|
|
21
70
|
// The worker's own canonical iss. STRICT: `config.issuer` is the serving URL
|
|
22
71
|
// by contract (both producers — buildIdentityMap / buildAuxIdentityMap — feed
|
|
23
72
|
// it `canonicalizeServingUrl(config.url)`), so a value that doesn't parse is
|
|
@@ -26,13 +75,6 @@ function makeResolveKeys(config) {
|
|
|
26
75
|
// worker's own hostname — which Cloudflare forbids — turning a config bug
|
|
27
76
|
// into an opaque per-call failure.
|
|
28
77
|
const selfIssuer = canonicalizeServingUrl(config.issuer);
|
|
29
|
-
// TODO(cache): Re-add JWKS caching with a short TTL or kid-miss retry.
|
|
30
|
-
// A prior implementation cached `createRemoteJWKSet` per issuer URL
|
|
31
|
-
// indefinitely. jose's internal cache (30s cooldown, 10min max-age) caused
|
|
32
|
-
// stale keys when the issuer restarted — the resolver served old keys and
|
|
33
|
-
// jose refused to refetch within the cooldown window. On CF Workers the
|
|
34
|
-
// module-level Map persisted across requests, making it worse. For now we
|
|
35
|
-
// create a fresh resolver per call (jose deduplicates concurrent fetches).
|
|
36
78
|
return async (issuer, _method, _kid) => {
|
|
37
79
|
const url = issuer;
|
|
38
80
|
// Self-issued credential (iss == this worker's own serving URL): resolve
|
|
@@ -47,20 +89,12 @@ function makeResolveKeys(config) {
|
|
|
47
89
|
canonical = undefined;
|
|
48
90
|
}
|
|
49
91
|
if (canonical === selfIssuer) {
|
|
50
|
-
return
|
|
92
|
+
return getLocalResolver(selfIssuer, config.privateKey);
|
|
51
93
|
}
|
|
52
94
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// signs with, but inbound creds from older clients may still carry
|
|
57
|
-
// the slug form. Coerce to a URL with a default `https://` scheme
|
|
58
|
-
// (matches kernel.astrale.ai canonical form). If the actual receiver
|
|
59
|
-
// is on http://localhost the receiver-side resolver still works
|
|
60
|
-
// because both endpoints are on localhost — but for prod targets
|
|
61
|
-
// requiring TLS this is the right default.
|
|
62
|
-
const normalized = /^https?:\/\//.test(url) ? url : `https://${url}`;
|
|
63
|
-
return createRemoteJWKSet(new URL(`${normalized}/.well-known/jwks.json`));
|
|
95
|
+
const jwksUrl = jwksUrlFor(url);
|
|
96
|
+
resolvedJwksUrls.add(jwksUrl);
|
|
97
|
+
return getRemoteResolver(jwksUrl);
|
|
64
98
|
};
|
|
65
99
|
}
|
|
66
100
|
/**
|
|
@@ -70,10 +104,31 @@ function makeResolveKeys(config) {
|
|
|
70
104
|
*/
|
|
71
105
|
export async function verifyInboundCredential(credential, config) {
|
|
72
106
|
// Verify using kernel-core's credential verification pipeline
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
const resolvedJwksUrls = new Set();
|
|
108
|
+
const deps = { methodResolver, resolveKeys: makeResolveKeys(config, resolvedJwksUrls) };
|
|
109
|
+
let verified;
|
|
110
|
+
try {
|
|
111
|
+
verified = await verifyCredential(deps, credential);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// A cached resolver can hold a stale key set after the issuer rotates:
|
|
115
|
+
// - new kid → jose kid-misses but its 30s fetch cooldown blocks the
|
|
116
|
+
// refetch (SigningKeyNotFoundError) — the incident that got a prior
|
|
117
|
+
// indefinite cache removed;
|
|
118
|
+
// - SAME kid, new key material (kernel kids derive from the subject, so
|
|
119
|
+
// a re-keyed issuer reuses its kid) → the signature check fails
|
|
120
|
+
// (SignatureVerificationError).
|
|
121
|
+
// Both: evict the resolver(s) this verification touched and retry ONCE
|
|
122
|
+
// with fresh ones. Forged-token spam thus costs at most one JWKS fetch
|
|
123
|
+
// per bad credential — equal to the uncached per-call baseline, never
|
|
124
|
+
// worse. An empty set means self-issued — refetching can't help, rethrow.
|
|
125
|
+
const staleKeySuspect = error instanceof SigningKeyNotFoundError || error instanceof SignatureVerificationError;
|
|
126
|
+
if (!staleKeySuspect || resolvedJwksUrls.size === 0)
|
|
127
|
+
throw error;
|
|
128
|
+
for (const url of resolvedJwksUrls)
|
|
129
|
+
remoteResolvers.delete(url);
|
|
130
|
+
verified = await verifyCredential(deps, credential);
|
|
131
|
+
}
|
|
77
132
|
// Validate audience matches this function's issuer (its serving URL).
|
|
78
133
|
// kernel-core's verifyAudience compares canonically.
|
|
79
134
|
verifyAudience(verified, config.issuer);
|
package/dist/auth/verify.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../../src/auth/verify.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAUH,OAAO,EACL,wBAAwB,EACxB,cAAc,EACd,cAAc,EACd,gBAAgB,GACjB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAY,MAAM,MAAM,CAAA;AAItE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAa9D,MAAM,cAAc,GAAG,IAAI,wBAAwB,CAAC,IAAI,cAAc,EAAE,CAAC,CAAA;AAEzE
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../../src/auth/verify.ts"],"names":[],"mappings":"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"}
|
|
@@ -34,6 +34,11 @@ export interface WorkerEntryConfig<TDeps> {
|
|
|
34
34
|
* Resolve the raw serving URL from `env` (+ the per-request origin, for workers
|
|
35
35
|
* that fall back to the request host). Defaults to the `WORKER_URL` env var.
|
|
36
36
|
* The result is always canonicalized before use.
|
|
37
|
+
*
|
|
38
|
+
* The `requestOrigin` honors an `X-Forwarded-Proto: https` upgrade (see
|
|
39
|
+
* `clientOrigin`), so a dev worker behind a TLS-terminating proxy (cloudflared
|
|
40
|
+
* tunnel, reverse proxy) resolves its public `https://` origin, not the raw
|
|
41
|
+
* `http://` one workerd sees.
|
|
37
42
|
*/
|
|
38
43
|
resolveUrl?: (env: TDeps, requestOrigin: string) => string;
|
|
39
44
|
/** Optional: the `SELF` service binding used to route same-host subrequests. */
|
|
@@ -55,6 +60,18 @@ export interface WorkerEntryConfig<TDeps> {
|
|
|
55
60
|
export interface WorkerEntry<TDeps> {
|
|
56
61
|
fetch(request: Request, env: TDeps): Response | Promise<Response>;
|
|
57
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The request origin as the CLIENT reached it — i.e. the origin a fallback
|
|
65
|
+
* serving URL (and therefore the `iss`) may be derived from. Behind a
|
|
66
|
+
* TLS-terminating proxy (a cloudflared tunnel in front of `wrangler dev`, any
|
|
67
|
+
* reverse proxy) the worker sees plain HTTP, so `request.url` says `http://…`
|
|
68
|
+
* while the public URL is `https://…`; the proxy advertises the original
|
|
69
|
+
* scheme via `X-Forwarded-Proto`. Honoring it is restricted to the http→https
|
|
70
|
+
* UPGRADE of the SAME host (never a downgrade, never a host change), so a
|
|
71
|
+
* spoofed header can at worst derive an `iss` the kernel's JWKS check then
|
|
72
|
+
* fails — it can never make this worker speak for another origin.
|
|
73
|
+
*/
|
|
74
|
+
export declare function clientOrigin(url: URL, request: Request): string;
|
|
58
75
|
export declare function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): WorkerEntry<TDeps>;
|
|
59
76
|
export {};
|
|
60
77
|
//# sourceMappingURL=worker-entry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker-entry.d.ts","sourceRoot":"","sources":["../../src/server/worker-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAMlD,KAAK,OAAO,GAAG;IAAE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAAE,CAAA;AAGxE,MAAM,WAAW,iBAAiB,CAAC,KAAK;IACtC;;;;OAIG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,KAAK,kBAAkB,CAAC,KAAK,CAAC,CAAA;IAC7D
|
|
1
|
+
{"version":3,"file":"worker-entry.d.ts","sourceRoot":"","sources":["../../src/server/worker-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAMlD,KAAK,OAAO,GAAG;IAAE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAAE,CAAA;AAGxE,MAAM,WAAW,iBAAiB,CAAC,KAAK;IACtC;;;;OAIG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,KAAK,kBAAkB,CAAC,KAAK,CAAC,CAAA;IAC7D;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,KAAK,MAAM,CAAA;IAC1D,gFAAgF;IAChF,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;IACxD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CACP,GAAG,EAAE,KAAK,EACV,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,KACb,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CAAA;IACzD;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAA;CAC3D;AAED,MAAM,WAAW,WAAW,CAAC,KAAK;IAChC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,KAAK,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAClE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAM/D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,iBAAiB,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CAmE7F"}
|
|
@@ -22,14 +22,46 @@
|
|
|
22
22
|
import { createRemoteServer } from './create';
|
|
23
23
|
import { requireEnv } from './require-env';
|
|
24
24
|
import { canonicalizeServingUrl } from './serving-url';
|
|
25
|
+
/**
|
|
26
|
+
* The request origin as the CLIENT reached it — i.e. the origin a fallback
|
|
27
|
+
* serving URL (and therefore the `iss`) may be derived from. Behind a
|
|
28
|
+
* TLS-terminating proxy (a cloudflared tunnel in front of `wrangler dev`, any
|
|
29
|
+
* reverse proxy) the worker sees plain HTTP, so `request.url` says `http://…`
|
|
30
|
+
* while the public URL is `https://…`; the proxy advertises the original
|
|
31
|
+
* scheme via `X-Forwarded-Proto`. Honoring it is restricted to the http→https
|
|
32
|
+
* UPGRADE of the SAME host (never a downgrade, never a host change), so a
|
|
33
|
+
* spoofed header can at worst derive an `iss` the kernel's JWKS check then
|
|
34
|
+
* fails — it can never make this worker speak for another origin.
|
|
35
|
+
*/
|
|
36
|
+
export function clientOrigin(url, request) {
|
|
37
|
+
if (url.protocol !== 'http:')
|
|
38
|
+
return url.origin;
|
|
39
|
+
const forwarded = request.headers.get('x-forwarded-proto');
|
|
40
|
+
// Multiple proxies append: "https, http" — the first hop is the client-facing one.
|
|
41
|
+
const scheme = forwarded?.split(',')[0]?.trim().toLowerCase();
|
|
42
|
+
return scheme === 'https' ? `https://${url.host}` : url.origin;
|
|
43
|
+
}
|
|
25
44
|
export function createWorkerEntry(config) {
|
|
26
|
-
|
|
45
|
+
// Cache the built app per distinct resolved URL — plural and bounded. On the
|
|
46
|
+
// request-origin fallback the URL legitimately alternates for one worker
|
|
47
|
+
// (direct http hits vs https-upgraded tunnel hits, workers.dev + custom
|
|
48
|
+
// domain), and a single slot would tear down and rebuild the whole app on
|
|
49
|
+
// every alternation. The bound caps abuse via attacker-minted Host /
|
|
50
|
+
// X-Forwarded-Proto values on that same fallback path.
|
|
51
|
+
const MAX_CACHED_APPS = 4;
|
|
52
|
+
const apps = new Map();
|
|
27
53
|
let self = null;
|
|
28
54
|
function getApp(url, env) {
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
const cached = apps.get(url);
|
|
56
|
+
if (cached)
|
|
57
|
+
return cached.app;
|
|
31
58
|
const { app } = createRemoteServer(config.build(url, env));
|
|
32
|
-
|
|
59
|
+
if (apps.size >= MAX_CACHED_APPS) {
|
|
60
|
+
const oldest = apps.keys().next().value;
|
|
61
|
+
if (oldest !== undefined)
|
|
62
|
+
apps.delete(oldest);
|
|
63
|
+
}
|
|
64
|
+
apps.set(url, { origin: new URL(url).origin, app });
|
|
33
65
|
return app;
|
|
34
66
|
}
|
|
35
67
|
// A Worker can't fetch its own hostname. When a SELF service binding is
|
|
@@ -41,11 +73,14 @@ export function createWorkerEntry(config) {
|
|
|
41
73
|
if (config.selfBinding) {
|
|
42
74
|
const originalFetch = globalThis.fetch;
|
|
43
75
|
globalThis.fetch = (async (input, init) => {
|
|
44
|
-
if (
|
|
76
|
+
if (apps.size > 0 && self) {
|
|
45
77
|
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
46
78
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
const origin = new URL(href).origin;
|
|
80
|
+
for (const cached of apps.values()) {
|
|
81
|
+
if (cached.origin === origin)
|
|
82
|
+
return self.fetch(new Request(input, init));
|
|
83
|
+
}
|
|
49
84
|
}
|
|
50
85
|
catch {
|
|
51
86
|
// non-absolute URL — fall through to the original fetch
|
|
@@ -68,7 +103,7 @@ export function createWorkerEntry(config) {
|
|
|
68
103
|
return handled;
|
|
69
104
|
}
|
|
70
105
|
const raw = config.resolveUrl
|
|
71
|
-
? config.resolveUrl(env, requestUrl
|
|
106
|
+
? config.resolveUrl(env, clientOrigin(requestUrl, request))
|
|
72
107
|
: requireEnv(env, 'WORKER_URL', "the worker's public serving URL (its iss identity)");
|
|
73
108
|
const url = canonicalizeServingUrl(raw);
|
|
74
109
|
const dispatched = config.rewriteRequest ? config.rewriteRequest(env, request) : request;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker-entry.js","sourceRoot":"","sources":["../../src/server/worker-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAC1C,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"worker-entry.js","sourceRoot":"","sources":["../../src/server/worker-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAC1C,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAgDtD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,YAAY,CAAC,GAAQ,EAAE,OAAgB;IACrD,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,GAAG,CAAC,MAAM,CAAA;IAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAA;IAC1D,mFAAmF;IACnF,MAAM,MAAM,GAAG,SAAS,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;IAC7D,OAAO,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,WAAW,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAA;AAChE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAQ,MAAgC;IACvE,6EAA6E;IAC7E,yEAAyE;IACzE,wEAAwE;IACxE,0EAA0E;IAC1E,qEAAqE;IACrE,uDAAuD;IACvD,MAAM,eAAe,GAAG,CAAC,CAAA;IACzB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAwC,CAAA;IAC5D,IAAI,IAAI,GAAmB,IAAI,CAAA;IAE/B,SAAS,MAAM,CAAC,GAAW,EAAE,GAAU;QACrC,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAC5B,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC,GAAG,CAAA;QAC7B,MAAM,EAAE,GAAG,EAAE,GAAG,kBAAkB,CAAQ,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;QACjE,IAAI,IAAI,CAAC,IAAI,IAAI,eAAe,EAAE,CAAC;YACjC,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAA;YACvC,IAAI,MAAM,KAAK,SAAS;gBAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QAC/C,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;QACnD,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,wEAAwE;IACxE,2EAA2E;IAC3E,+DAA+D;IAC/D,+EAA+E;IAC/E,8EAA8E;IAC9E,4EAA4E;IAC5E,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACvB,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,CAAA;QACtC,UAAU,CAAC,KAAK,GAAG,CAAC,KAAK,EAAE,KAAwB,EAAE,IAAkB,EAAE,EAAE;YACzE,IAAI,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;gBAC1B,MAAM,IAAI,GACR,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,YAAY,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAA;gBACnF,IAAI,CAAC;oBACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,CAAA;oBACnC,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;wBACnC,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM;4BAAE,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAA;oBAC3E,CAAC;gBACH,CAAC;gBAAC,MAAM,CAAC;oBACP,wDAAwD;gBAC1D,CAAC;YACH,CAAC;YACD,OAAO,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;QACnC,CAAC,CAAiB,CAAA;IACpB,CAAC;IAED,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,OAAgB,EAAE,GAAU;YACtC,IAAI,MAAM,CAAC,WAAW;gBAAE,IAAI,KAAK,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,IAAI,CAAA;YAChE,4DAA4D;YAC5D,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;YACnF,IAAI,MAAM,CAAC,MAAM,IAAI,UAAU,EAAE,CAAC;gBAChC,wEAAwE;gBACxE,2EAA2E;gBAC3E,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,CAAC,CAAA;gBAC7D,IAAI,OAAO,KAAK,SAAS;oBAAE,OAAO,OAAO,CAAA;YAC3C,CAAC;YACD,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU;gBAC3B,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE,YAAY,CAAC,UAAW,EAAE,OAAO,CAAC,CAAC;gBAC5D,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,oDAAoD,CAAC,CAAA;YACvF,MAAM,GAAG,GAAG,sBAAsB,CAAC,GAAG,CAAC,CAAA;YACvC,MAAM,UAAU,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;YACxF,OAAO,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;QAC3C,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/package.json
CHANGED
package/src/auth/verify.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
CredentialMethodResolver,
|
|
19
19
|
MethodRegistry,
|
|
20
|
+
SignatureVerificationError,
|
|
21
|
+
SigningKeyNotFoundError,
|
|
20
22
|
verifyAudience,
|
|
21
23
|
verifyCredential,
|
|
22
24
|
} from '@astrale-os/kernel-core'
|
|
@@ -40,14 +42,72 @@ export type VerifiedInbound = {
|
|
|
40
42
|
|
|
41
43
|
const methodResolver = new CredentialMethodResolver(new MethodRegistry())
|
|
42
44
|
|
|
45
|
+
// JWKS resolvers cached per JWKS URL (module-level, like the pools/selfIds
|
|
46
|
+
// Maps in kernel-client.ts). jose handles freshness WITHIN a resolver: 10min
|
|
47
|
+
// max-age, 30s fetch cooldown, and auto-refetch on kid-miss when not cooling
|
|
48
|
+
// down. The one gap — issuer restarts with new keys while the cooldown pins
|
|
49
|
+
// the old set (the incident that got a prior indefinite cache removed) — is
|
|
50
|
+
// covered by evict-and-retry-once in `verifyInboundCredential`, so indefinite
|
|
51
|
+
// Map residency is safe and the per-call JWKS fetch is gone.
|
|
52
|
+
const remoteResolvers = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
|
|
53
|
+
|
|
54
|
+
// Self-issued credentials verify against the worker's own in-memory key;
|
|
55
|
+
// cache the local JWKS per canonical self-issuer (one key per worker) so the
|
|
56
|
+
// public-JWK derivation isn't redone on every request.
|
|
57
|
+
const localResolvers = new Map<string, ReturnType<typeof createLocalJWKSet>>()
|
|
58
|
+
|
|
59
|
+
/** Clear cached JWKS resolvers. Used in tests when keys rotate between fixtures. */
|
|
60
|
+
export function clearJwksCache(): void {
|
|
61
|
+
remoteResolvers.clear()
|
|
62
|
+
localResolvers.clear()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* M-28 fix: a bare-slug iss (`mails.localhost`) makes
|
|
67
|
+
* `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid URL
|
|
68
|
+
* string". The dispatcher's identity map normalizes the iss it signs with,
|
|
69
|
+
* but inbound creds from older clients may still carry the slug form. Coerce
|
|
70
|
+
* to a URL with a default `https://` scheme (matches kernel.astrale.ai
|
|
71
|
+
* canonical form). If the actual receiver is on http://localhost the
|
|
72
|
+
* receiver-side resolver still works because both endpoints are on localhost
|
|
73
|
+
* — but for prod targets requiring TLS this is the right default.
|
|
74
|
+
*/
|
|
75
|
+
function jwksUrlFor(issuer: string): string {
|
|
76
|
+
const normalized = /^https?:\/\//.test(issuer) ? issuer : `https://${issuer}`
|
|
77
|
+
return `${normalized}/.well-known/jwks.json`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getRemoteResolver(jwksUrl: string): ReturnType<typeof createRemoteJWKSet> {
|
|
81
|
+
let resolver = remoteResolvers.get(jwksUrl)
|
|
82
|
+
if (!resolver) {
|
|
83
|
+
resolver = createRemoteJWKSet(new URL(jwksUrl))
|
|
84
|
+
remoteResolvers.set(jwksUrl, resolver)
|
|
85
|
+
}
|
|
86
|
+
return resolver
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getLocalResolver(
|
|
90
|
+
selfIssuer: string,
|
|
91
|
+
privateKey: RemoteIdentityConfig['privateKey'],
|
|
92
|
+
): ReturnType<typeof createLocalJWKSet> {
|
|
93
|
+
let resolver = localResolvers.get(selfIssuer)
|
|
94
|
+
if (!resolver) {
|
|
95
|
+
resolver = createLocalJWKSet({ keys: [derivePublicJwk(privateKey) as JWK] })
|
|
96
|
+
localResolvers.set(selfIssuer, resolver)
|
|
97
|
+
}
|
|
98
|
+
return resolver
|
|
99
|
+
}
|
|
100
|
+
|
|
43
101
|
/**
|
|
44
102
|
* Build the key resolver for one verifying server. Captures `config` so it can
|
|
45
103
|
* short-circuit the server's OWN issuer (`config.issuer`): a self-issued
|
|
46
104
|
* credential is verified against the in-memory public key, never fetched — a
|
|
47
105
|
* Worker can't fetch its own hostname, and it already holds the key. Every other
|
|
48
|
-
* issuer is resolved
|
|
106
|
+
* issuer is resolved via the cached per-URL JWKS resolvers above; every JWKS
|
|
107
|
+
* URL touched is recorded in `resolvedJwksUrls` so the caller can evict
|
|
108
|
+
* exactly those resolvers if verification fails on an unknown signing key.
|
|
49
109
|
*/
|
|
50
|
-
function makeResolveKeys(config: RemoteIdentityConfig) {
|
|
110
|
+
function makeResolveKeys(config: RemoteIdentityConfig, resolvedJwksUrls: Set<string>) {
|
|
51
111
|
// The worker's own canonical iss. STRICT: `config.issuer` is the serving URL
|
|
52
112
|
// by contract (both producers — buildIdentityMap / buildAuxIdentityMap — feed
|
|
53
113
|
// it `canonicalizeServingUrl(config.url)`), so a value that doesn't parse is
|
|
@@ -57,13 +117,6 @@ function makeResolveKeys(config: RemoteIdentityConfig) {
|
|
|
57
117
|
// into an opaque per-call failure.
|
|
58
118
|
const selfIssuer = canonicalizeServingUrl(config.issuer)
|
|
59
119
|
|
|
60
|
-
// TODO(cache): Re-add JWKS caching with a short TTL or kid-miss retry.
|
|
61
|
-
// A prior implementation cached `createRemoteJWKSet` per issuer URL
|
|
62
|
-
// indefinitely. jose's internal cache (30s cooldown, 10min max-age) caused
|
|
63
|
-
// stale keys when the issuer restarted — the resolver served old keys and
|
|
64
|
-
// jose refused to refetch within the cooldown window. On CF Workers the
|
|
65
|
-
// module-level Map persisted across requests, making it worse. For now we
|
|
66
|
-
// create a fresh resolver per call (jose deduplicates concurrent fetches).
|
|
67
120
|
return async (issuer: IssuerId, _method: string, _kid?: string) => {
|
|
68
121
|
const url = issuer as string
|
|
69
122
|
|
|
@@ -78,21 +131,13 @@ function makeResolveKeys(config: RemoteIdentityConfig) {
|
|
|
78
131
|
canonical = undefined
|
|
79
132
|
}
|
|
80
133
|
if (canonical === selfIssuer) {
|
|
81
|
-
return
|
|
134
|
+
return getLocalResolver(selfIssuer, config.privateKey)
|
|
82
135
|
}
|
|
83
136
|
}
|
|
84
137
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// signs with, but inbound creds from older clients may still carry
|
|
89
|
-
// the slug form. Coerce to a URL with a default `https://` scheme
|
|
90
|
-
// (matches kernel.astrale.ai canonical form). If the actual receiver
|
|
91
|
-
// is on http://localhost the receiver-side resolver still works
|
|
92
|
-
// because both endpoints are on localhost — but for prod targets
|
|
93
|
-
// requiring TLS this is the right default.
|
|
94
|
-
const normalized = /^https?:\/\//.test(url) ? url : `https://${url}`
|
|
95
|
-
return createRemoteJWKSet(new URL(`${normalized}/.well-known/jwks.json`))
|
|
138
|
+
const jwksUrl = jwksUrlFor(url)
|
|
139
|
+
resolvedJwksUrls.add(jwksUrl)
|
|
140
|
+
return getRemoteResolver(jwksUrl)
|
|
96
141
|
}
|
|
97
142
|
}
|
|
98
143
|
|
|
@@ -106,13 +151,29 @@ export async function verifyInboundCredential(
|
|
|
106
151
|
config: RemoteIdentityConfig,
|
|
107
152
|
): Promise<VerifiedInbound> {
|
|
108
153
|
// Verify using kernel-core's credential verification pipeline
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
154
|
+
const resolvedJwksUrls = new Set<string>()
|
|
155
|
+
const deps = { methodResolver, resolveKeys: makeResolveKeys(config, resolvedJwksUrls) }
|
|
156
|
+
let verified: VerifiedCredential
|
|
157
|
+
try {
|
|
158
|
+
verified = await verifyCredential(deps, credential)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// A cached resolver can hold a stale key set after the issuer rotates:
|
|
161
|
+
// - new kid → jose kid-misses but its 30s fetch cooldown blocks the
|
|
162
|
+
// refetch (SigningKeyNotFoundError) — the incident that got a prior
|
|
163
|
+
// indefinite cache removed;
|
|
164
|
+
// - SAME kid, new key material (kernel kids derive from the subject, so
|
|
165
|
+
// a re-keyed issuer reuses its kid) → the signature check fails
|
|
166
|
+
// (SignatureVerificationError).
|
|
167
|
+
// Both: evict the resolver(s) this verification touched and retry ONCE
|
|
168
|
+
// with fresh ones. Forged-token spam thus costs at most one JWKS fetch
|
|
169
|
+
// per bad credential — equal to the uncached per-call baseline, never
|
|
170
|
+
// worse. An empty set means self-issued — refetching can't help, rethrow.
|
|
171
|
+
const staleKeySuspect =
|
|
172
|
+
error instanceof SigningKeyNotFoundError || error instanceof SignatureVerificationError
|
|
173
|
+
if (!staleKeySuspect || resolvedJwksUrls.size === 0) throw error
|
|
174
|
+
for (const url of resolvedJwksUrls) remoteResolvers.delete(url)
|
|
175
|
+
verified = await verifyCredential(deps, credential)
|
|
176
|
+
}
|
|
116
177
|
|
|
117
178
|
// Validate audience matches this function's issuer (its serving URL).
|
|
118
179
|
// kernel-core's verifyAudience compares canonically.
|
|
@@ -40,6 +40,11 @@ export interface WorkerEntryConfig<TDeps> {
|
|
|
40
40
|
* Resolve the raw serving URL from `env` (+ the per-request origin, for workers
|
|
41
41
|
* that fall back to the request host). Defaults to the `WORKER_URL` env var.
|
|
42
42
|
* The result is always canonicalized before use.
|
|
43
|
+
*
|
|
44
|
+
* The `requestOrigin` honors an `X-Forwarded-Proto: https` upgrade (see
|
|
45
|
+
* `clientOrigin`), so a dev worker behind a TLS-terminating proxy (cloudflared
|
|
46
|
+
* tunnel, reverse proxy) resolves its public `https://` origin, not the raw
|
|
47
|
+
* `http://` one workerd sees.
|
|
43
48
|
*/
|
|
44
49
|
resolveUrl?: (env: TDeps, requestOrigin: string) => string
|
|
45
50
|
/** Optional: the `SELF` service binding used to route same-host subrequests. */
|
|
@@ -67,14 +72,45 @@ export interface WorkerEntry<TDeps> {
|
|
|
67
72
|
fetch(request: Request, env: TDeps): Response | Promise<Response>
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The request origin as the CLIENT reached it — i.e. the origin a fallback
|
|
77
|
+
* serving URL (and therefore the `iss`) may be derived from. Behind a
|
|
78
|
+
* TLS-terminating proxy (a cloudflared tunnel in front of `wrangler dev`, any
|
|
79
|
+
* reverse proxy) the worker sees plain HTTP, so `request.url` says `http://…`
|
|
80
|
+
* while the public URL is `https://…`; the proxy advertises the original
|
|
81
|
+
* scheme via `X-Forwarded-Proto`. Honoring it is restricted to the http→https
|
|
82
|
+
* UPGRADE of the SAME host (never a downgrade, never a host change), so a
|
|
83
|
+
* spoofed header can at worst derive an `iss` the kernel's JWKS check then
|
|
84
|
+
* fails — it can never make this worker speak for another origin.
|
|
85
|
+
*/
|
|
86
|
+
export function clientOrigin(url: URL, request: Request): string {
|
|
87
|
+
if (url.protocol !== 'http:') return url.origin
|
|
88
|
+
const forwarded = request.headers.get('x-forwarded-proto')
|
|
89
|
+
// Multiple proxies append: "https, http" — the first hop is the client-facing one.
|
|
90
|
+
const scheme = forwarded?.split(',')[0]?.trim().toLowerCase()
|
|
91
|
+
return scheme === 'https' ? `https://${url.host}` : url.origin
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
export function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): WorkerEntry<TDeps> {
|
|
71
|
-
|
|
95
|
+
// Cache the built app per distinct resolved URL — plural and bounded. On the
|
|
96
|
+
// request-origin fallback the URL legitimately alternates for one worker
|
|
97
|
+
// (direct http hits vs https-upgraded tunnel hits, workers.dev + custom
|
|
98
|
+
// domain), and a single slot would tear down and rebuild the whole app on
|
|
99
|
+
// every alternation. The bound caps abuse via attacker-minted Host /
|
|
100
|
+
// X-Forwarded-Proto values on that same fallback path.
|
|
101
|
+
const MAX_CACHED_APPS = 4
|
|
102
|
+
const apps = new Map<string, { origin: string; app: App }>()
|
|
72
103
|
let self: Fetcher | null = null
|
|
73
104
|
|
|
74
105
|
function getApp(url: string, env: TDeps): App {
|
|
75
|
-
|
|
106
|
+
const cached = apps.get(url)
|
|
107
|
+
if (cached) return cached.app
|
|
76
108
|
const { app } = createRemoteServer<TDeps>(config.build(url, env))
|
|
77
|
-
|
|
109
|
+
if (apps.size >= MAX_CACHED_APPS) {
|
|
110
|
+
const oldest = apps.keys().next().value
|
|
111
|
+
if (oldest !== undefined) apps.delete(oldest)
|
|
112
|
+
}
|
|
113
|
+
apps.set(url, { origin: new URL(url).origin, app })
|
|
78
114
|
return app
|
|
79
115
|
}
|
|
80
116
|
|
|
@@ -87,11 +123,14 @@ export function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): Work
|
|
|
87
123
|
if (config.selfBinding) {
|
|
88
124
|
const originalFetch = globalThis.fetch
|
|
89
125
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
90
|
-
if (
|
|
126
|
+
if (apps.size > 0 && self) {
|
|
91
127
|
const href =
|
|
92
128
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
93
129
|
try {
|
|
94
|
-
|
|
130
|
+
const origin = new URL(href).origin
|
|
131
|
+
for (const cached of apps.values()) {
|
|
132
|
+
if (cached.origin === origin) return self.fetch(new Request(input, init))
|
|
133
|
+
}
|
|
95
134
|
} catch {
|
|
96
135
|
// non-absolute URL — fall through to the original fetch
|
|
97
136
|
}
|
|
@@ -112,7 +151,7 @@ export function createWorkerEntry<TDeps>(config: WorkerEntryConfig<TDeps>): Work
|
|
|
112
151
|
if (handled !== undefined) return handled
|
|
113
152
|
}
|
|
114
153
|
const raw = config.resolveUrl
|
|
115
|
-
? config.resolveUrl(env, requestUrl
|
|
154
|
+
? config.resolveUrl(env, clientOrigin(requestUrl!, request))
|
|
116
155
|
: requireEnv(env, 'WORKER_URL', "the worker's public serving URL (its iss identity)")
|
|
117
156
|
const url = canonicalizeServingUrl(raw)
|
|
118
157
|
const dispatched = config.rewriteRequest ? config.rewriteRequest(env, request) : request
|