nucleus-core-ts 0.9.705 → 0.9.707

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.
Files changed (29) hide show
  1. package/dist/index.js +11 -11
  2. package/dist/src/Client/Proxy/authz.d.ts +74 -0
  3. package/dist/src/Client/Proxy/authz.js +202 -0
  4. package/dist/src/Client/Proxy/authz.test.d.ts +1 -0
  5. package/dist/src/Client/Proxy/authz.test.js +235 -0
  6. package/dist/src/Client/Proxy/httpProxy.js +96 -11
  7. package/dist/src/Client/Proxy/types.d.ts +40 -0
  8. package/dist/src/ElysiaPlugin/routes/authorization/discovery.test.d.ts +1 -0
  9. package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +50 -0
  10. package/dist/src/ElysiaPlugin/routes/domain/hostnames.d.ts +4 -2
  11. package/dist/src/ElysiaPlugin/routes/domain/hostnames.test.d.ts +1 -0
  12. package/dist/src/ElysiaPlugin/routes/payment/marketplace/authz.test.d.ts +1 -0
  13. package/dist/src/ElysiaPlugin/routes/payment/marketplace/types.d.ts +9 -0
  14. package/dist/src/ElysiaPlugin/routes/pubsub/daprAuth.test.d.ts +1 -0
  15. package/dist/src/ElysiaPlugin/routes/pubsub/types.d.ts +8 -0
  16. package/dist/src/ElysiaPlugin/routes/storage/cdn.test.d.ts +1 -0
  17. package/dist/src/ElysiaPlugin/utils.d.ts +5 -0
  18. package/dist/src/Services/Authorization/EndpointDiscovery/discovery.test.d.ts +1 -0
  19. package/dist/src/Services/Authorization/EndpointDiscovery/index.d.ts +68 -0
  20. package/dist/src/Services/Authorization/EndpointDiscovery/seed.d.ts +60 -0
  21. package/dist/src/Services/Authorization/EndpointDiscovery/seed.test.d.ts +1 -0
  22. package/dist/src/Services/Authorization/types.d.ts +6 -0
  23. package/dist/src/Services/OAuth/providers/microsoft.d.ts +17 -0
  24. package/dist/src/Services/OAuth/providers/microsoft.test.d.ts +1 -0
  25. package/dist/src/Services/Tenant/TenantRegistry.d.ts +1 -1
  26. package/dist/src/Services/Tenant/helpers.d.ts +9 -1
  27. package/dist/src/Services/Tenant/isTrustedSource.test.d.ts +1 -0
  28. package/dist/src/types.d.ts +49 -0
  29. package/package.json +113 -113
@@ -0,0 +1,74 @@
1
+ import type { ManifestRoute, ProxyAuthorizeConfig, ProxyLogger } from './types';
2
+ /**
3
+ * Reverse-proxy claim-gate. Enforces the discovered `{serviceId}.{tag}.{op}` claims
4
+ * (see nucleus endpointDiscovery) at the proxy: a request is allowed only if the
5
+ * VERIFIED access token holds a claim that satisfies the route's required action.
6
+ *
7
+ * Security: the token is HS256-verified with the access-token secret before its
8
+ * claims are trusted — decoding without verifying would let a forged token bypass
9
+ * the gate. Requires `jwtClaimsMode: 'embed'` on the IDP (claims travel in the JWT).
10
+ */
11
+ export type { ManifestRoute, ProxyAuthorizeConfig } from './types';
12
+ /** HS256-verify a JWT and return its payload, or null if invalid/expired/tampered. */
13
+ export declare function verifyJwtHS256(token: string, secret: string): Record<string, unknown> | null;
14
+ /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */
15
+ export declare function claimSatisfies(userClaims: string[], required: string): boolean;
16
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */
17
+ export declare function pathMatchesTemplate(template: string, concrete: string): boolean;
18
+ export declare function findRequiredAction(manifest: ManifestRoute[], method: string, path: string): string | null;
19
+ export interface AccessDecision {
20
+ decision: 'allow' | 'deny';
21
+ reason: string;
22
+ action: string | null;
23
+ }
24
+ /**
25
+ * Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
26
+ * (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
27
+ * this stays mode-agnostic and easy to test.
28
+ */
29
+ export declare function evaluateAccess(args: {
30
+ manifest: ManifestRoute[];
31
+ method: string;
32
+ path: string;
33
+ authenticated: boolean;
34
+ roles: string[];
35
+ claims: string[];
36
+ onUnmapped: 'allow' | 'deny';
37
+ publicPaths?: string[];
38
+ godminRole: string;
39
+ }): AccessDecision;
40
+ /** Fetches + caches a service's route manifest from the IDP. */
41
+ export declare class ManifestCache {
42
+ private readonly cfg;
43
+ private readonly fetchImpl;
44
+ private readonly logger?;
45
+ private routes;
46
+ private fetchedAtMs;
47
+ private inflight;
48
+ constructor(cfg: ProxyAuthorizeConfig['manifest'] & {
49
+ serviceId: string;
50
+ }, fetchImpl?: typeof fetch, logger?: ProxyLogger | undefined);
51
+ private get ttlMs();
52
+ private refresh;
53
+ get(): Promise<ManifestRoute[]>;
54
+ }
55
+ /** Fetches + caches the role→claim-actions map (resolve mode) from the IDP. */
56
+ export declare class RoleClaimsCache {
57
+ private readonly cfg;
58
+ private readonly fetchImpl;
59
+ private readonly logger?;
60
+ private map;
61
+ private fetchedAtMs;
62
+ private inflight;
63
+ constructor(cfg: {
64
+ url: string;
65
+ token?: string;
66
+ refreshSec?: number;
67
+ serviceId: string;
68
+ }, fetchImpl?: typeof fetch, logger?: ProxyLogger | undefined);
69
+ private get ttlMs();
70
+ private refresh;
71
+ get(): Promise<Record<string, string[]>>;
72
+ }
73
+ /** Resolve a user's roles to their claim actions using the role-claims map. */
74
+ export declare function resolveClaimsFromRoles(roles: string[], roleClaims: Record<string, string[]>): string[];
@@ -0,0 +1,202 @@
1
+ function _define_property(obj, key, value) {
2
+ if (key in obj) {
3
+ Object.defineProperty(obj, key, {
4
+ value: value,
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true
8
+ });
9
+ } else {
10
+ obj[key] = value;
11
+ }
12
+ return obj;
13
+ }
14
+ import { createHmac, timingSafeEqual } from 'node:crypto';
15
+ /** HS256-verify a JWT and return its payload, or null if invalid/expired/tampered. */ export function verifyJwtHS256(token, secret) {
16
+ const parts = token.split('.');
17
+ if (parts.length !== 3) return null;
18
+ const [header, payloadB64, sigB64] = parts;
19
+ if (!header || !payloadB64 || !sigB64) return null;
20
+ try {
21
+ const expected = createHmac('sha256', secret).update(`${header}.${payloadB64}`).digest();
22
+ const got = Buffer.from(sigB64, 'base64url');
23
+ if (expected.length !== got.length || !timingSafeEqual(expected, got)) return null;
24
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
25
+ if (typeof payload.exp === 'number' && payload.exp * 1000 < Date.now()) return null;
26
+ return payload;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Hierarchical prefix match: a shorter claim grants longer actions (`agent` ⇒ `agent.x.y`). */ export function claimSatisfies(userClaims, required) {
32
+ return userClaims.some((uc)=>required === uc || required.startsWith(`${uc}.`));
33
+ }
34
+ /** True when a concrete request path matches a templated OpenAPI path (params = wildcards). */ export function pathMatchesTemplate(template, concrete) {
35
+ const t = template.replace(/\/+$/, '').split('/');
36
+ const c = concrete.replace(/\/+$/, '').split('/');
37
+ if (t.length !== c.length) return false;
38
+ for(let i = 0; i < t.length; i++){
39
+ const seg = t[i];
40
+ if (seg === undefined) return false;
41
+ if (seg.startsWith('{') && seg.endsWith('}')) continue;
42
+ if (seg !== c[i]) return false;
43
+ }
44
+ return true;
45
+ }
46
+ export function findRequiredAction(manifest, method, path) {
47
+ const m = method.toUpperCase();
48
+ for (const r of manifest){
49
+ if (r.method === m && pathMatchesTemplate(r.path, path)) return r.action;
50
+ }
51
+ return null;
52
+ }
53
+ function matchesGlob(path, patterns) {
54
+ if (!patterns?.length) return false;
55
+ for (const raw of patterns){
56
+ const p = raw.trim();
57
+ if (!p) continue;
58
+ if (p.endsWith('/*')) {
59
+ const prefix = p.slice(0, -2);
60
+ if (path === prefix || path.startsWith(`${prefix}/`)) return true;
61
+ } else if (p.endsWith('*')) {
62
+ if (path.startsWith(p.slice(0, -1))) return true;
63
+ } else if (path === p) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ /**
70
+ * Pure authorization decision. `claims`/`roles` are ALREADY resolved by the caller
71
+ * (from the JWT in embed mode, or via the role-claims manifest in resolve mode), so
72
+ * this stays mode-agnostic and easy to test.
73
+ */ export function evaluateAccess(args) {
74
+ const { manifest, method, path, authenticated, roles, claims, onUnmapped, publicPaths, godminRole } = args;
75
+ if (matchesGlob(path, publicPaths)) return {
76
+ decision: 'allow',
77
+ reason: 'public',
78
+ action: null
79
+ };
80
+ const action = findRequiredAction(manifest, method, path);
81
+ if (action === null) {
82
+ return {
83
+ decision: onUnmapped,
84
+ reason: `unmapped:${onUnmapped}`,
85
+ action: null
86
+ };
87
+ }
88
+ if (!authenticated) return {
89
+ decision: 'deny',
90
+ reason: 'no-valid-token',
91
+ action
92
+ };
93
+ if (roles.includes(godminRole)) return {
94
+ decision: 'allow',
95
+ reason: 'godmin',
96
+ action
97
+ };
98
+ if (claimSatisfies(claims, action)) return {
99
+ decision: 'allow',
100
+ reason: 'claim',
101
+ action
102
+ };
103
+ return {
104
+ decision: 'deny',
105
+ reason: 'missing-claim',
106
+ action
107
+ };
108
+ }
109
+ /** Fetches + caches a service's route manifest from the IDP. */ export class ManifestCache {
110
+ get ttlMs() {
111
+ return (this.cfg.refreshSec ?? 300) * 1000;
112
+ }
113
+ async refresh() {
114
+ const url = `${this.cfg.url}${this.cfg.url.includes('?') ? '&' : '?'}service=${encodeURIComponent(this.cfg.serviceId)}`;
115
+ const headers = {};
116
+ if (this.cfg.token) headers['x-discovery-token'] = this.cfg.token;
117
+ const res = await this.fetchImpl(url, {
118
+ headers
119
+ });
120
+ if (!res.ok) throw new Error(`manifest fetch ${res.status}`);
121
+ const body = await res.json();
122
+ this.routes = body.data?.routes ?? [];
123
+ this.fetchedAtMs = Date.now();
124
+ }
125
+ async get() {
126
+ const stale = Date.now() - this.fetchedAtMs > this.ttlMs;
127
+ if (stale && !this.inflight) {
128
+ this.inflight = this.refresh().catch((err)=>{
129
+ this.logger?.warn?.(`[Proxy authz] manifest refresh failed for ${this.cfg.serviceId}: ${err instanceof Error ? err.message : String(err)}`);
130
+ }).finally(()=>{
131
+ this.inflight = null;
132
+ });
133
+ }
134
+ // First-ever load must block; later refreshes serve the last good copy.
135
+ if (this.fetchedAtMs === 0 && this.inflight) await this.inflight;
136
+ return this.routes;
137
+ }
138
+ constructor(cfg, fetchImpl = fetch, logger){
139
+ _define_property(this, "cfg", void 0);
140
+ _define_property(this, "fetchImpl", void 0);
141
+ _define_property(this, "logger", void 0);
142
+ _define_property(this, "routes", void 0);
143
+ _define_property(this, "fetchedAtMs", void 0);
144
+ _define_property(this, "inflight", void 0);
145
+ this.cfg = cfg;
146
+ this.fetchImpl = fetchImpl;
147
+ this.logger = logger;
148
+ this.routes = [];
149
+ this.fetchedAtMs = 0;
150
+ this.inflight = null;
151
+ }
152
+ }
153
+ /** Fetches + caches the role→claim-actions map (resolve mode) from the IDP. */ export class RoleClaimsCache {
154
+ get ttlMs() {
155
+ return (this.cfg.refreshSec ?? 300) * 1000;
156
+ }
157
+ async refresh() {
158
+ const url = `${this.cfg.url}${this.cfg.url.includes('?') ? '&' : '?'}service=${encodeURIComponent(this.cfg.serviceId)}`;
159
+ const headers = {};
160
+ if (this.cfg.token) headers['x-discovery-token'] = this.cfg.token;
161
+ const res = await this.fetchImpl(url, {
162
+ headers
163
+ });
164
+ if (!res.ok) throw new Error(`role-claims manifest fetch ${res.status}`);
165
+ const body = await res.json();
166
+ this.map = body.data?.roleClaims ?? {};
167
+ this.fetchedAtMs = Date.now();
168
+ }
169
+ async get() {
170
+ const stale = Date.now() - this.fetchedAtMs > this.ttlMs;
171
+ if (stale && !this.inflight) {
172
+ this.inflight = this.refresh().catch((err)=>{
173
+ this.logger?.warn?.(`[Proxy authz] role-claims refresh failed for ${this.cfg.serviceId}: ${err instanceof Error ? err.message : String(err)}`);
174
+ }).finally(()=>{
175
+ this.inflight = null;
176
+ });
177
+ }
178
+ if (this.fetchedAtMs === 0 && this.inflight) await this.inflight;
179
+ return this.map;
180
+ }
181
+ constructor(cfg, fetchImpl = fetch, logger){
182
+ _define_property(this, "cfg", void 0);
183
+ _define_property(this, "fetchImpl", void 0);
184
+ _define_property(this, "logger", void 0);
185
+ _define_property(this, "map", void 0);
186
+ _define_property(this, "fetchedAtMs", void 0);
187
+ _define_property(this, "inflight", void 0);
188
+ this.cfg = cfg;
189
+ this.fetchImpl = fetchImpl;
190
+ this.logger = logger;
191
+ this.map = {};
192
+ this.fetchedAtMs = 0;
193
+ this.inflight = null;
194
+ }
195
+ }
196
+ /** Resolve a user's roles to their claim actions using the role-claims map. */ export function resolveClaimsFromRoles(roles, roleClaims) {
197
+ const out = new Set();
198
+ for (const r of roles)for (const a of roleClaims[r] ?? [])out.add(a);
199
+ return [
200
+ ...out
201
+ ];
202
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,235 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { describe, expect, it } from 'bun:test';
3
+ import { claimSatisfies, evaluateAccess, findRequiredAction, resolveClaimsFromRoles, verifyJwtHS256 } from './authz';
4
+ const b64 = (o)=>Buffer.from(JSON.stringify(o)).toString('base64url');
5
+ function signHS256(payload, secret) {
6
+ const header = b64({
7
+ alg: 'HS256',
8
+ typ: 'JWT'
9
+ });
10
+ const body = b64(payload);
11
+ const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
12
+ return `${header}.${body}.${sig}`;
13
+ }
14
+ const SECRET = 'test-secret';
15
+ describe('verifyJwtHS256', ()=>{
16
+ it('verifies a correctly signed, unexpired token', ()=>{
17
+ const t = signHS256({
18
+ sub: 'u1',
19
+ claims: [
20
+ 'agent.templates.read'
21
+ ]
22
+ }, SECRET);
23
+ expect(verifyJwtHS256(t, SECRET)?.sub).toBe('u1');
24
+ });
25
+ it('rejects a wrong secret', ()=>{
26
+ const t = signHS256({
27
+ sub: 'u1'
28
+ }, SECRET);
29
+ expect(verifyJwtHS256(t, 'other-secret')).toBeNull();
30
+ });
31
+ it('rejects a tampered payload (forged claims)', ()=>{
32
+ const t = signHS256({
33
+ sub: 'u1',
34
+ claims: []
35
+ }, SECRET);
36
+ const [h, , s] = t.split('.');
37
+ const forged = `${h}.${b64({
38
+ sub: 'u1',
39
+ claims: [
40
+ 'agent.templates.delete'
41
+ ]
42
+ })}.${s}`;
43
+ expect(verifyJwtHS256(forged, SECRET)).toBeNull();
44
+ });
45
+ it('rejects an expired token', ()=>{
46
+ const t = signHS256({
47
+ sub: 'u1',
48
+ exp: Math.floor(Date.now() / 1000) - 60
49
+ }, SECRET);
50
+ expect(verifyJwtHS256(t, SECRET)).toBeNull();
51
+ });
52
+ it('rejects malformed input', ()=>{
53
+ expect(verifyJwtHS256('not.a.jwt.token', SECRET)).toBeNull();
54
+ expect(verifyJwtHS256('garbage', SECRET)).toBeNull();
55
+ });
56
+ });
57
+ describe('claimSatisfies (prefix)', ()=>{
58
+ it('exact + prefix grants', ()=>{
59
+ expect(claimSatisfies([
60
+ 'agent.templates.read'
61
+ ], 'agent.templates.read')).toBe(true);
62
+ expect(claimSatisfies([
63
+ 'agent.templates'
64
+ ], 'agent.templates.read')).toBe(true);
65
+ expect(claimSatisfies([
66
+ 'agent'
67
+ ], 'agent.templates.read')).toBe(true);
68
+ });
69
+ it('does not over-grant across siblings', ()=>{
70
+ expect(claimSatisfies([
71
+ 'agent.templates.read'
72
+ ], 'agent.templates.delete')).toBe(false);
73
+ expect(claimSatisfies([
74
+ 'agent.templatesx'
75
+ ], 'agent.templates.read')).toBe(false);
76
+ });
77
+ });
78
+ const MANIFEST = [
79
+ {
80
+ action: 'agent.templates.list',
81
+ path: '/api/v1/templates',
82
+ method: 'GET'
83
+ },
84
+ {
85
+ action: 'agent.templates.delete',
86
+ path: '/api/v1/templates/{id}',
87
+ method: 'DELETE'
88
+ }
89
+ ];
90
+ describe('findRequiredAction', ()=>{
91
+ it('resolves templated routes', ()=>{
92
+ expect(findRequiredAction(MANIFEST, 'GET', '/api/v1/templates')).toBe('agent.templates.list');
93
+ expect(findRequiredAction(MANIFEST, 'DELETE', '/api/v1/templates/9')).toBe('agent.templates.delete');
94
+ expect(findRequiredAction(MANIFEST, 'POST', '/api/v1/templates')).toBeNull();
95
+ });
96
+ });
97
+ describe('evaluateAccess', ()=>{
98
+ const base = {
99
+ manifest: MANIFEST,
100
+ onUnmapped: 'deny',
101
+ godminRole: 'godmin',
102
+ authenticated: true,
103
+ roles: [],
104
+ claims: []
105
+ };
106
+ it('allows a public path without a claim', ()=>{
107
+ const d = evaluateAccess({
108
+ ...base,
109
+ method: 'GET',
110
+ path: '/health',
111
+ authenticated: false,
112
+ publicPaths: [
113
+ '/health'
114
+ ]
115
+ });
116
+ expect(d.decision).toBe('allow');
117
+ });
118
+ it('denies an unmapped path when onUnmapped=deny', ()=>{
119
+ const d = evaluateAccess({
120
+ ...base,
121
+ method: 'GET',
122
+ path: '/api/v1/unknown'
123
+ });
124
+ expect(d.decision).toBe('deny');
125
+ });
126
+ it('allows an unmapped path when onUnmapped=allow', ()=>{
127
+ const d = evaluateAccess({
128
+ ...base,
129
+ onUnmapped: 'allow',
130
+ method: 'GET',
131
+ path: '/api/v1/unknown'
132
+ });
133
+ expect(d.decision).toBe('allow');
134
+ });
135
+ it('denies a mapped route with no valid token', ()=>{
136
+ const d = evaluateAccess({
137
+ ...base,
138
+ method: 'GET',
139
+ path: '/api/v1/templates',
140
+ authenticated: false
141
+ });
142
+ expect(d).toMatchObject({
143
+ decision: 'deny',
144
+ reason: 'no-valid-token'
145
+ });
146
+ });
147
+ it('allows godmin regardless of claims', ()=>{
148
+ const d = evaluateAccess({
149
+ ...base,
150
+ method: 'DELETE',
151
+ path: '/api/v1/templates/1',
152
+ roles: [
153
+ 'godmin'
154
+ ]
155
+ });
156
+ expect(d).toMatchObject({
157
+ decision: 'allow',
158
+ reason: 'godmin'
159
+ });
160
+ });
161
+ it('allows when a claim satisfies the required action', ()=>{
162
+ const d = evaluateAccess({
163
+ ...base,
164
+ method: 'GET',
165
+ path: '/api/v1/templates',
166
+ claims: [
167
+ 'agent.templates'
168
+ ]
169
+ });
170
+ expect(d).toMatchObject({
171
+ decision: 'allow',
172
+ reason: 'claim'
173
+ });
174
+ });
175
+ it('denies when the caller lacks the required claim', ()=>{
176
+ const d = evaluateAccess({
177
+ ...base,
178
+ method: 'DELETE',
179
+ path: '/api/v1/templates/1',
180
+ claims: [
181
+ 'agent.templates.list'
182
+ ]
183
+ });
184
+ expect(d).toMatchObject({
185
+ decision: 'deny',
186
+ reason: 'missing-claim'
187
+ });
188
+ });
189
+ });
190
+ describe('resolveClaimsFromRoles (resolve mode)', ()=>{
191
+ const roleClaims = {
192
+ 'agent-viewer': [
193
+ 'agent.templates.list',
194
+ 'agent.templates.get'
195
+ ],
196
+ 'agent-admin': [
197
+ 'agent'
198
+ ]
199
+ };
200
+ it('unions the claim actions of the caller-held roles', ()=>{
201
+ expect(resolveClaimsFromRoles([
202
+ 'agent-viewer'
203
+ ], roleClaims).sort()).toEqual([
204
+ 'agent.templates.get',
205
+ 'agent.templates.list'
206
+ ]);
207
+ });
208
+ it('dedupes across roles and ignores unknown roles', ()=>{
209
+ expect(resolveClaimsFromRoles([
210
+ 'agent-admin',
211
+ 'nope'
212
+ ], roleClaims)).toEqual([
213
+ 'agent'
214
+ ]);
215
+ // resolved claims then feed evaluateAccess: 'agent' prefix grants any agent.* action
216
+ const d = evaluateAccess({
217
+ manifest: MANIFEST,
218
+ method: 'DELETE',
219
+ path: '/api/v1/templates/1',
220
+ authenticated: true,
221
+ roles: [
222
+ 'agent-admin'
223
+ ],
224
+ claims: resolveClaimsFromRoles([
225
+ 'agent-admin'
226
+ ], roleClaims),
227
+ onUnmapped: 'deny',
228
+ godminRole: 'godmin'
229
+ });
230
+ expect(d).toMatchObject({
231
+ decision: 'allow',
232
+ reason: 'claim'
233
+ });
234
+ });
235
+ });
@@ -1,3 +1,4 @@
1
+ import { evaluateAccess, ManifestCache, resolveClaimsFromRoles, RoleClaimsCache, verifyJwtHS256 } from './authz';
1
2
  import { createProxyLogger, matchPath, parseCookies, rewritePath } from './utils';
2
3
  const pendingRefreshes = new Map();
3
4
  function extractAccessTokenFromSetCookie(setCookieHeaders, accessCookieName) {
@@ -132,6 +133,79 @@ function extractSubFromJwt(token) {
132
133
  }
133
134
  export function createHttpProxyHandler(config) {
134
135
  const logger = createProxyLogger('HTTP Proxy', config.debug ?? false);
136
+ // One manifest cache per authorize-enabled service (shared across requests).
137
+ const manifestCaches = new Map();
138
+ const roleClaimsCaches = new Map();
139
+ for (const t of config.targets){
140
+ if (t.authorize) {
141
+ manifestCaches.set(t.authorize.serviceId, new ManifestCache({
142
+ ...t.authorize.manifest,
143
+ serviceId: t.authorize.serviceId
144
+ }, fetch, logger));
145
+ if (t.authorize.claimsMode === 'resolve' && t.authorize.manifest.roleClaimsUrl) {
146
+ roleClaimsCaches.set(t.authorize.serviceId, new RoleClaimsCache({
147
+ url: t.authorize.manifest.roleClaimsUrl,
148
+ token: t.authorize.manifest.token,
149
+ refreshSec: t.authorize.manifest.refreshSec,
150
+ serviceId: t.authorize.serviceId
151
+ }, fetch, logger));
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Claim-gate a request for a target. Returns a 403 Response to block (enforce
157
+ * mode), or null to allow (also null in audit mode, where a would-block is logged).
158
+ */ async function checkAuthorize(req, target, path) {
159
+ const az = target.authorize;
160
+ if (!az) return null;
161
+ const cache = manifestCaches.get(az.serviceId);
162
+ if (!cache) return null;
163
+ const manifest = await cache.get();
164
+ const upstreamPath = target.pathRewrite ? rewritePath(path, target.pathRewrite) : path;
165
+ const cookies = parseCookies(req.headers.get('cookie'));
166
+ const bearer = az.jwt.headerName ? (req.headers.get(az.jwt.headerName) ?? '').replace(/^Bearer\s+/i, '') : '';
167
+ const tokenStr = cookies[az.jwt.cookieName ?? 'access_token'] || bearer || '';
168
+ const payload = tokenStr ? verifyJwtHS256(tokenStr, az.jwt.secret) : null;
169
+ const roles = Array.isArray(payload?.roles) ? payload.roles : [];
170
+ // embed: claims live in the JWT. resolve: the JWT carries only roles; resolve
171
+ // them to claims via the role-claims manifest (matches IDP jwtClaimsMode).
172
+ let claims;
173
+ if (az.claimsMode === 'resolve') {
174
+ const roleClaimsCache = roleClaimsCaches.get(az.serviceId);
175
+ const roleClaims = roleClaimsCache ? await roleClaimsCache.get() : {};
176
+ claims = resolveClaimsFromRoles(roles, roleClaims);
177
+ } else {
178
+ claims = Array.isArray(payload?.claims) ? payload.claims : [];
179
+ }
180
+ const decision = evaluateAccess({
181
+ manifest,
182
+ method: req.method,
183
+ path: upstreamPath,
184
+ authenticated: payload !== null,
185
+ roles,
186
+ claims,
187
+ onUnmapped: az.onUnmapped ?? 'deny',
188
+ publicPaths: az.publicPaths,
189
+ godminRole: az.godminRole ?? 'godmin'
190
+ });
191
+ if (decision.decision === 'allow') return null;
192
+ const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
193
+ if ((az.mode ?? 'enforce') === 'audit') {
194
+ logger.warn(`[authz audit] WOULD block ${detail}`);
195
+ return null;
196
+ }
197
+ logger.warn(`[authz] 403 ${detail}`);
198
+ return new Response(JSON.stringify({
199
+ success: false,
200
+ message: 'Forbidden',
201
+ reason: decision.reason
202
+ }), {
203
+ status: 403,
204
+ headers: {
205
+ 'content-type': 'application/json'
206
+ }
207
+ });
208
+ }
135
209
  function findTarget(path) {
136
210
  for (const target of config.targets){
137
211
  if (matchPath(path, target.paths)) {
@@ -178,18 +252,24 @@ export function createHttpProxyHandler(config) {
178
252
  } else {
179
253
  headers.delete('host');
180
254
  }
181
- // Inject real client IP as x-forwarded-for / x-real-ip if not already present
255
+ // SECURITY: this proxy is the trust boundary for the client IP. A previous
256
+ // version APPENDED the socket IP to an existing X-Forwarded-For, so a client
257
+ // could send `X-Forwarded-For: <trusted-ip>` and the chain became
258
+ // `<trusted-ip>, <real>` — downstream allow-lists that read the LEADING entry
259
+ // (e.g. tenant allowedIps) then trusted the spoofed value. OVERWRITE, never
260
+ // append, and strip every client-supplied alternative IP header.
261
+ for (const h of [
262
+ 'cf-connecting-ip',
263
+ 'true-client-ip',
264
+ 'x-client-ip'
265
+ ])headers.delete(h);
182
266
  if (clientIp) {
183
- const existingXff = headers.get('x-forwarded-for');
184
- if (existingXff) {
185
- // Append proxy client IP to existing chain
186
- headers.set('x-forwarded-for', `${existingXff}, ${clientIp}`);
187
- } else {
188
- headers.set('x-forwarded-for', clientIp);
189
- }
190
- if (!headers.has('x-real-ip')) {
191
- headers.set('x-real-ip', clientIp);
192
- }
267
+ headers.set('x-forwarded-for', clientIp);
268
+ headers.set('x-real-ip', clientIp);
269
+ } else {
270
+ // Couldn't resolve the socket IP — do not forward a client-controlled value.
271
+ headers.delete('x-forwarded-for');
272
+ headers.delete('x-real-ip');
193
273
  }
194
274
  if (target.headers) {
195
275
  for (const [key, value] of Object.entries(target.headers)){
@@ -237,6 +317,11 @@ export function createHttpProxyHandler(config) {
237
317
  if (!target) {
238
318
  return null;
239
319
  }
320
+ // Claim-gate BEFORE forwarding: a denied request never reaches the upstream.
321
+ if (target.authorize) {
322
+ const blocked = await checkAuthorize(req, target, path);
323
+ if (blocked) return blocked;
324
+ }
240
325
  const startTime = performance.now();
241
326
  const backendUrl = buildBackendUrl(path, target);
242
327
  const finalUrl = url.search ? `${backendUrl}${url.search}` : backendUrl;