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.
- package/dist/index.js +11 -11
- package/dist/src/Client/Proxy/authz.d.ts +74 -0
- package/dist/src/Client/Proxy/authz.js +202 -0
- package/dist/src/Client/Proxy/authz.test.d.ts +1 -0
- package/dist/src/Client/Proxy/authz.test.js +235 -0
- package/dist/src/Client/Proxy/httpProxy.js +96 -11
- package/dist/src/Client/Proxy/types.d.ts +40 -0
- package/dist/src/ElysiaPlugin/routes/authorization/discovery.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/authorization/index.d.ts +50 -0
- package/dist/src/ElysiaPlugin/routes/domain/hostnames.d.ts +4 -2
- package/dist/src/ElysiaPlugin/routes/domain/hostnames.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/payment/marketplace/authz.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/payment/marketplace/types.d.ts +9 -0
- package/dist/src/ElysiaPlugin/routes/pubsub/daprAuth.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/pubsub/types.d.ts +8 -0
- package/dist/src/ElysiaPlugin/routes/storage/cdn.test.d.ts +1 -0
- package/dist/src/ElysiaPlugin/utils.d.ts +5 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/discovery.test.d.ts +1 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/index.d.ts +68 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/seed.d.ts +60 -0
- package/dist/src/Services/Authorization/EndpointDiscovery/seed.test.d.ts +1 -0
- package/dist/src/Services/Authorization/types.d.ts +6 -0
- package/dist/src/Services/OAuth/providers/microsoft.d.ts +17 -0
- package/dist/src/Services/OAuth/providers/microsoft.test.d.ts +1 -0
- package/dist/src/Services/Tenant/TenantRegistry.d.ts +1 -1
- package/dist/src/Services/Tenant/helpers.d.ts +9 -1
- package/dist/src/Services/Tenant/isTrustedSource.test.d.ts +1 -0
- package/dist/src/types.d.ts +49 -0
- 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
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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;
|