nucleus-core-ts 0.9.705 → 0.9.706
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 +48 -0
- package/dist/src/Client/Proxy/authz.js +150 -0
- package/dist/src/Client/Proxy/authz.test.d.ts +1 -0
- package/dist/src/Client/Proxy/authz.test.js +192 -0
- package/dist/src/Client/Proxy/httpProxy.js +74 -11
- package/dist/src/Client/Proxy/types.d.ts +28 -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 +53 -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,48 @@
|
|
|
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
|
+
/** Pure authorization decision (mode-independent). */
|
|
25
|
+
export declare function evaluateAccess(args: {
|
|
26
|
+
manifest: ManifestRoute[];
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
payload: Record<string, unknown> | null;
|
|
30
|
+
onUnmapped: 'allow' | 'deny';
|
|
31
|
+
publicPaths?: string[];
|
|
32
|
+
godminRole: string;
|
|
33
|
+
}): AccessDecision;
|
|
34
|
+
/** Fetches + caches a service's route manifest from the IDP. */
|
|
35
|
+
export declare class ManifestCache {
|
|
36
|
+
private readonly cfg;
|
|
37
|
+
private readonly fetchImpl;
|
|
38
|
+
private readonly logger?;
|
|
39
|
+
private routes;
|
|
40
|
+
private fetchedAtMs;
|
|
41
|
+
private inflight;
|
|
42
|
+
constructor(cfg: ProxyAuthorizeConfig['manifest'] & {
|
|
43
|
+
serviceId: string;
|
|
44
|
+
}, fetchImpl?: typeof fetch, logger?: ProxyLogger | undefined);
|
|
45
|
+
private get ttlMs();
|
|
46
|
+
private refresh;
|
|
47
|
+
get(): Promise<ManifestRoute[]>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
/** Pure authorization decision (mode-independent). */ export function evaluateAccess(args) {
|
|
70
|
+
const { manifest, method, path, payload, onUnmapped, publicPaths, godminRole } = args;
|
|
71
|
+
if (matchesGlob(path, publicPaths)) return {
|
|
72
|
+
decision: 'allow',
|
|
73
|
+
reason: 'public',
|
|
74
|
+
action: null
|
|
75
|
+
};
|
|
76
|
+
const action = findRequiredAction(manifest, method, path);
|
|
77
|
+
if (action === null) {
|
|
78
|
+
return {
|
|
79
|
+
decision: onUnmapped,
|
|
80
|
+
reason: `unmapped:${onUnmapped}`,
|
|
81
|
+
action: null
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (!payload) return {
|
|
85
|
+
decision: 'deny',
|
|
86
|
+
reason: 'no-valid-token',
|
|
87
|
+
action
|
|
88
|
+
};
|
|
89
|
+
const roles = Array.isArray(payload.roles) ? payload.roles : [];
|
|
90
|
+
if (roles.includes(godminRole)) return {
|
|
91
|
+
decision: 'allow',
|
|
92
|
+
reason: 'godmin',
|
|
93
|
+
action
|
|
94
|
+
};
|
|
95
|
+
const claims = Array.isArray(payload.claims) ? payload.claims : [];
|
|
96
|
+
if (claimSatisfies(claims, action)) return {
|
|
97
|
+
decision: 'allow',
|
|
98
|
+
reason: 'claim',
|
|
99
|
+
action
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
decision: 'deny',
|
|
103
|
+
reason: 'missing-claim',
|
|
104
|
+
action
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Fetches + caches a service's route manifest from the IDP. */ export class ManifestCache {
|
|
108
|
+
get ttlMs() {
|
|
109
|
+
return (this.cfg.refreshSec ?? 300) * 1000;
|
|
110
|
+
}
|
|
111
|
+
async refresh() {
|
|
112
|
+
const url = `${this.cfg.url}${this.cfg.url.includes('?') ? '&' : '?'}service=${encodeURIComponent(this.cfg.serviceId)}`;
|
|
113
|
+
const headers = {};
|
|
114
|
+
if (this.cfg.token) headers['x-discovery-token'] = this.cfg.token;
|
|
115
|
+
const res = await this.fetchImpl(url, {
|
|
116
|
+
headers
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) throw new Error(`manifest fetch ${res.status}`);
|
|
119
|
+
const body = await res.json();
|
|
120
|
+
this.routes = body.data?.routes ?? [];
|
|
121
|
+
this.fetchedAtMs = Date.now();
|
|
122
|
+
}
|
|
123
|
+
async get() {
|
|
124
|
+
const stale = Date.now() - this.fetchedAtMs > this.ttlMs;
|
|
125
|
+
if (stale && !this.inflight) {
|
|
126
|
+
this.inflight = this.refresh().catch((err)=>{
|
|
127
|
+
this.logger?.warn?.(`[Proxy authz] manifest refresh failed for ${this.cfg.serviceId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
+
}).finally(()=>{
|
|
129
|
+
this.inflight = null;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// First-ever load must block; later refreshes serve the last good copy.
|
|
133
|
+
if (this.fetchedAtMs === 0 && this.inflight) await this.inflight;
|
|
134
|
+
return this.routes;
|
|
135
|
+
}
|
|
136
|
+
constructor(cfg, fetchImpl = fetch, logger){
|
|
137
|
+
_define_property(this, "cfg", void 0);
|
|
138
|
+
_define_property(this, "fetchImpl", void 0);
|
|
139
|
+
_define_property(this, "logger", void 0);
|
|
140
|
+
_define_property(this, "routes", void 0);
|
|
141
|
+
_define_property(this, "fetchedAtMs", void 0);
|
|
142
|
+
_define_property(this, "inflight", void 0);
|
|
143
|
+
this.cfg = cfg;
|
|
144
|
+
this.fetchImpl = fetchImpl;
|
|
145
|
+
this.logger = logger;
|
|
146
|
+
this.routes = [];
|
|
147
|
+
this.fetchedAtMs = 0;
|
|
148
|
+
this.inflight = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
import { claimSatisfies, evaluateAccess, findRequiredAction, 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
|
+
};
|
|
103
|
+
const payload = (claims, roles = [])=>({
|
|
104
|
+
claims,
|
|
105
|
+
roles
|
|
106
|
+
});
|
|
107
|
+
it('allows a public path without a claim', ()=>{
|
|
108
|
+
const d = evaluateAccess({
|
|
109
|
+
...base,
|
|
110
|
+
method: 'GET',
|
|
111
|
+
path: '/health',
|
|
112
|
+
payload: null,
|
|
113
|
+
publicPaths: [
|
|
114
|
+
'/health'
|
|
115
|
+
]
|
|
116
|
+
});
|
|
117
|
+
expect(d.decision).toBe('allow');
|
|
118
|
+
});
|
|
119
|
+
it('denies an unmapped path when onUnmapped=deny', ()=>{
|
|
120
|
+
const d = evaluateAccess({
|
|
121
|
+
...base,
|
|
122
|
+
method: 'GET',
|
|
123
|
+
path: '/api/v1/unknown',
|
|
124
|
+
payload: payload([])
|
|
125
|
+
});
|
|
126
|
+
expect(d.decision).toBe('deny');
|
|
127
|
+
});
|
|
128
|
+
it('allows an unmapped path when onUnmapped=allow', ()=>{
|
|
129
|
+
const d = evaluateAccess({
|
|
130
|
+
...base,
|
|
131
|
+
onUnmapped: 'allow',
|
|
132
|
+
method: 'GET',
|
|
133
|
+
path: '/api/v1/unknown',
|
|
134
|
+
payload: payload([])
|
|
135
|
+
});
|
|
136
|
+
expect(d.decision).toBe('allow');
|
|
137
|
+
});
|
|
138
|
+
it('denies a mapped route with no valid token', ()=>{
|
|
139
|
+
const d = evaluateAccess({
|
|
140
|
+
...base,
|
|
141
|
+
method: 'GET',
|
|
142
|
+
path: '/api/v1/templates',
|
|
143
|
+
payload: null
|
|
144
|
+
});
|
|
145
|
+
expect(d).toMatchObject({
|
|
146
|
+
decision: 'deny',
|
|
147
|
+
reason: 'no-valid-token'
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
it('allows godmin regardless of claims', ()=>{
|
|
151
|
+
const d = evaluateAccess({
|
|
152
|
+
...base,
|
|
153
|
+
method: 'DELETE',
|
|
154
|
+
path: '/api/v1/templates/1',
|
|
155
|
+
payload: payload([], [
|
|
156
|
+
'godmin'
|
|
157
|
+
])
|
|
158
|
+
});
|
|
159
|
+
expect(d).toMatchObject({
|
|
160
|
+
decision: 'allow',
|
|
161
|
+
reason: 'godmin'
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
it('allows when a claim satisfies the required action', ()=>{
|
|
165
|
+
const d = evaluateAccess({
|
|
166
|
+
...base,
|
|
167
|
+
method: 'GET',
|
|
168
|
+
path: '/api/v1/templates',
|
|
169
|
+
payload: payload([
|
|
170
|
+
'agent.templates'
|
|
171
|
+
])
|
|
172
|
+
});
|
|
173
|
+
expect(d).toMatchObject({
|
|
174
|
+
decision: 'allow',
|
|
175
|
+
reason: 'claim'
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
it('denies when the caller lacks the required claim', ()=>{
|
|
179
|
+
const d = evaluateAccess({
|
|
180
|
+
...base,
|
|
181
|
+
method: 'DELETE',
|
|
182
|
+
path: '/api/v1/templates/1',
|
|
183
|
+
payload: payload([
|
|
184
|
+
'agent.templates.list'
|
|
185
|
+
])
|
|
186
|
+
});
|
|
187
|
+
expect(d).toMatchObject({
|
|
188
|
+
decision: 'deny',
|
|
189
|
+
reason: 'missing-claim'
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evaluateAccess, ManifestCache, 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,57 @@ 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
|
+
for (const t of config.targets){
|
|
139
|
+
if (t.authorize) {
|
|
140
|
+
manifestCaches.set(t.authorize.serviceId, new ManifestCache({
|
|
141
|
+
...t.authorize.manifest,
|
|
142
|
+
serviceId: t.authorize.serviceId
|
|
143
|
+
}, fetch, logger));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Claim-gate a request for a target. Returns a 403 Response to block (enforce
|
|
148
|
+
* mode), or null to allow (also null in audit mode, where a would-block is logged).
|
|
149
|
+
*/ async function checkAuthorize(req, target, path) {
|
|
150
|
+
const az = target.authorize;
|
|
151
|
+
if (!az) return null;
|
|
152
|
+
const cache = manifestCaches.get(az.serviceId);
|
|
153
|
+
if (!cache) return null;
|
|
154
|
+
const manifest = await cache.get();
|
|
155
|
+
const upstreamPath = target.pathRewrite ? rewritePath(path, target.pathRewrite) : path;
|
|
156
|
+
const cookies = parseCookies(req.headers.get('cookie'));
|
|
157
|
+
const bearer = az.jwt.headerName ? (req.headers.get(az.jwt.headerName) ?? '').replace(/^Bearer\s+/i, '') : '';
|
|
158
|
+
const tokenStr = cookies[az.jwt.cookieName ?? 'access_token'] || bearer || '';
|
|
159
|
+
const payload = tokenStr ? verifyJwtHS256(tokenStr, az.jwt.secret) : null;
|
|
160
|
+
const decision = evaluateAccess({
|
|
161
|
+
manifest,
|
|
162
|
+
method: req.method,
|
|
163
|
+
path: upstreamPath,
|
|
164
|
+
payload,
|
|
165
|
+
onUnmapped: az.onUnmapped ?? 'deny',
|
|
166
|
+
publicPaths: az.publicPaths,
|
|
167
|
+
godminRole: az.godminRole ?? 'godmin'
|
|
168
|
+
});
|
|
169
|
+
if (decision.decision === 'allow') return null;
|
|
170
|
+
const detail = `${req.method} ${path} (${decision.reason}${decision.action ? ` need ${decision.action}` : ''})`;
|
|
171
|
+
if ((az.mode ?? 'enforce') === 'audit') {
|
|
172
|
+
logger.warn(`[authz audit] WOULD block ${detail}`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
logger.warn(`[authz] 403 ${detail}`);
|
|
176
|
+
return new Response(JSON.stringify({
|
|
177
|
+
success: false,
|
|
178
|
+
message: 'Forbidden',
|
|
179
|
+
reason: decision.reason
|
|
180
|
+
}), {
|
|
181
|
+
status: 403,
|
|
182
|
+
headers: {
|
|
183
|
+
'content-type': 'application/json'
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
135
187
|
function findTarget(path) {
|
|
136
188
|
for (const target of config.targets){
|
|
137
189
|
if (matchPath(path, target.paths)) {
|
|
@@ -178,18 +230,24 @@ export function createHttpProxyHandler(config) {
|
|
|
178
230
|
} else {
|
|
179
231
|
headers.delete('host');
|
|
180
232
|
}
|
|
181
|
-
//
|
|
233
|
+
// SECURITY: this proxy is the trust boundary for the client IP. A previous
|
|
234
|
+
// version APPENDED the socket IP to an existing X-Forwarded-For, so a client
|
|
235
|
+
// could send `X-Forwarded-For: <trusted-ip>` and the chain became
|
|
236
|
+
// `<trusted-ip>, <real>` — downstream allow-lists that read the LEADING entry
|
|
237
|
+
// (e.g. tenant allowedIps) then trusted the spoofed value. OVERWRITE, never
|
|
238
|
+
// append, and strip every client-supplied alternative IP header.
|
|
239
|
+
for (const h of [
|
|
240
|
+
'cf-connecting-ip',
|
|
241
|
+
'true-client-ip',
|
|
242
|
+
'x-client-ip'
|
|
243
|
+
])headers.delete(h);
|
|
182
244
|
if (clientIp) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
if (!headers.has('x-real-ip')) {
|
|
191
|
-
headers.set('x-real-ip', clientIp);
|
|
192
|
-
}
|
|
245
|
+
headers.set('x-forwarded-for', clientIp);
|
|
246
|
+
headers.set('x-real-ip', clientIp);
|
|
247
|
+
} else {
|
|
248
|
+
// Couldn't resolve the socket IP — do not forward a client-controlled value.
|
|
249
|
+
headers.delete('x-forwarded-for');
|
|
250
|
+
headers.delete('x-real-ip');
|
|
193
251
|
}
|
|
194
252
|
if (target.headers) {
|
|
195
253
|
for (const [key, value] of Object.entries(target.headers)){
|
|
@@ -237,6 +295,11 @@ export function createHttpProxyHandler(config) {
|
|
|
237
295
|
if (!target) {
|
|
238
296
|
return null;
|
|
239
297
|
}
|
|
298
|
+
// Claim-gate BEFORE forwarding: a denied request never reaches the upstream.
|
|
299
|
+
if (target.authorize) {
|
|
300
|
+
const blocked = await checkAuthorize(req, target, path);
|
|
301
|
+
if (blocked) return blocked;
|
|
302
|
+
}
|
|
240
303
|
const startTime = performance.now();
|
|
241
304
|
const backendUrl = buildBackendUrl(path, target);
|
|
242
305
|
const finalUrl = url.search ? `${backendUrl}${url.search}` : backendUrl;
|
|
@@ -34,6 +34,32 @@ export interface TokenRefreshConfig {
|
|
|
34
34
|
onRefreshSuccess?: (path: string) => void;
|
|
35
35
|
onRefreshFailure?: (path: string, error: string) => void;
|
|
36
36
|
}
|
|
37
|
+
export interface ManifestRoute {
|
|
38
|
+
action: string;
|
|
39
|
+
path: string;
|
|
40
|
+
method: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Claim-gate config for a proxy target: enforce the discovered endpoint claims
|
|
44
|
+
* (nucleus endpointDiscovery) at the proxy. Requires the IDP in `jwtClaimsMode: 'embed'`.
|
|
45
|
+
*/
|
|
46
|
+
export interface ProxyAuthorizeConfig {
|
|
47
|
+
serviceId: string;
|
|
48
|
+
manifest: {
|
|
49
|
+
url: string;
|
|
50
|
+
token?: string;
|
|
51
|
+
refreshSec?: number;
|
|
52
|
+
};
|
|
53
|
+
jwt: {
|
|
54
|
+
secret: string;
|
|
55
|
+
cookieName?: string;
|
|
56
|
+
headerName?: string;
|
|
57
|
+
};
|
|
58
|
+
mode?: 'audit' | 'enforce';
|
|
59
|
+
onUnmapped?: 'allow' | 'deny';
|
|
60
|
+
publicPaths?: string[];
|
|
61
|
+
godminRole?: string;
|
|
62
|
+
}
|
|
37
63
|
export interface HttpProxyTarget {
|
|
38
64
|
url: string;
|
|
39
65
|
paths: string[];
|
|
@@ -48,6 +74,8 @@ export interface HttpProxyTarget {
|
|
|
48
74
|
cookieName: string;
|
|
49
75
|
headerName?: string;
|
|
50
76
|
};
|
|
77
|
+
/** Enforce discovered endpoint claims for this target at the proxy. */
|
|
78
|
+
authorize?: ProxyAuthorizeConfig;
|
|
51
79
|
tokenRefresh?: TokenRefreshConfig;
|
|
52
80
|
changeOrigin?: boolean;
|
|
53
81
|
timeout?: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
2
|
+
import { Elysia } from 'elysia';
|
|
3
|
+
import { type EndpointDiscoveryConfig } from 'src/Services/Authorization/EndpointDiscovery/seed';
|
|
4
|
+
import type { Logger } from 'src/Services/Logger';
|
|
5
|
+
export interface AuthorizationRoutesConfig {
|
|
6
|
+
db: NodePgDatabase;
|
|
7
|
+
schemaTables: Record<string, unknown>;
|
|
8
|
+
logger: Logger;
|
|
9
|
+
/** endpointDiscovery config, plus an optional shared token for the proxy. */
|
|
10
|
+
discovery: EndpointDiscoveryConfig & {
|
|
11
|
+
token?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Endpoint-discovery admin + manifest routes.
|
|
16
|
+
* - `POST /authorization/discover` — godmin re-runs discovery + reconcile.
|
|
17
|
+
* - `GET /authorization/route-manifest` — the templated route→claim table the reverse
|
|
18
|
+
* proxy consumes. Reachable by godmin OR by a caller presenting the configured
|
|
19
|
+
* discovery token (`x-discovery-token`), so the proxy (which has no user session)
|
|
20
|
+
* can fetch it. The token resolves from `endpointDiscovery.token` then the
|
|
21
|
+
* `NUCLEUS_DISCOVERY_TOKEN` env.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createAuthorizationDiscoveryRoutes(cfg: AuthorizationRoutesConfig): Elysia<"", {
|
|
24
|
+
decorator: {};
|
|
25
|
+
store: {};
|
|
26
|
+
derive: {};
|
|
27
|
+
resolve: {};
|
|
28
|
+
}, {
|
|
29
|
+
typebox: {};
|
|
30
|
+
error: {};
|
|
31
|
+
}, {
|
|
32
|
+
schema: {};
|
|
33
|
+
standaloneSchema: {};
|
|
34
|
+
macro: {};
|
|
35
|
+
macroFn: {};
|
|
36
|
+
parser: {};
|
|
37
|
+
response: {};
|
|
38
|
+
}, {}, {
|
|
39
|
+
derive: {};
|
|
40
|
+
resolve: {};
|
|
41
|
+
schema: {};
|
|
42
|
+
standaloneSchema: {};
|
|
43
|
+
response: {};
|
|
44
|
+
}, {
|
|
45
|
+
derive: {};
|
|
46
|
+
resolve: {};
|
|
47
|
+
schema: {};
|
|
48
|
+
standaloneSchema: {};
|
|
49
|
+
response: {};
|
|
50
|
+
}>;
|
|
@@ -2,8 +2,10 @@ import { Elysia } from 'elysia';
|
|
|
2
2
|
import type { DomainRouteConfig } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Custom-hostname lifecycle routes. Management requires an authenticated user
|
|
5
|
-
* (x-user-id)
|
|
6
|
-
*
|
|
5
|
+
* (x-user-id). Creation additionally binds the hostname's target schema to the
|
|
6
|
+
* caller's own verified tenant (`x-tenant-schema`) unless the caller is godmin —
|
|
7
|
+
* see the createHostname handler. Mounted under config.basePath (default
|
|
8
|
+
* "/domains").
|
|
7
9
|
*/
|
|
8
10
|
export declare function createHostnameRoutes(config: DomainRouteConfig): Elysia<"", {
|
|
9
11
|
decorator: {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -32,6 +32,15 @@ export declare const isAdmin: (ctx: MarketplaceCtx) => boolean;
|
|
|
32
32
|
* not an operator.
|
|
33
33
|
*/
|
|
34
34
|
export declare const requireAdmin: (ctx: MarketplaceCtx) => string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Authorizes READING an owner's financial ledger (balance, splits, settlement
|
|
37
|
+
* policy, payout requests, disputes). Operators may read any owner. A non-operator
|
|
38
|
+
* may only read their OWN user-scoped ledger — the framework keeps no generic
|
|
39
|
+
* seller/tenant → user mapping, so ownerType 'seller'/'tenant'/'platform' cannot be
|
|
40
|
+
* proven to belong to the caller and is operator-only by default. Without this,
|
|
41
|
+
* any authenticated user could read ANY owner's balances/payouts (financial IDOR).
|
|
42
|
+
*/
|
|
43
|
+
export declare const canReadOwnerLedger: (ctx: MarketplaceCtx, ownerType: string | undefined, ownerId: string | undefined | null) => boolean;
|
|
35
44
|
export declare const failResponse: (ctx: MarketplaceCtx, status: number, message: string) => {
|
|
36
45
|
success: boolean;
|
|
37
46
|
message: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -70,6 +70,14 @@ export interface PubSubRouteConfig {
|
|
|
70
70
|
debounceMs: number;
|
|
71
71
|
};
|
|
72
72
|
cleanupIntervalMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Shared secret Dapr presents (as the `dapr-api-token` header) when it POSTs a
|
|
75
|
+
* delivered message to the subscription endpoint. When set, the endpoint rejects
|
|
76
|
+
* any inbound event whose token does not match — without it, ANY caller who can
|
|
77
|
+
* reach the route could inject realtime events to any user. Resolved from
|
|
78
|
+
* config.pubsub.daprApiToken, then env APP_API_TOKEN / DAPR_API_TOKEN.
|
|
79
|
+
*/
|
|
80
|
+
daprApiToken?: string;
|
|
73
81
|
getLiveMonitoringService?: () => LiveMonitoringService | null;
|
|
74
82
|
/**
|
|
75
83
|
* Optional WebSocket handshake authenticator. When provided, every WS
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -71,6 +71,11 @@ export interface ValidationResult {
|
|
|
71
71
|
errors: ValidationError[];
|
|
72
72
|
}
|
|
73
73
|
export declare function validatePayload(payload: Record<string, unknown>, columns: NucleusColumn[], isPartial?: boolean): ValidationResult;
|
|
74
|
+
/**
|
|
75
|
+
* True when a column name is an auth-secret that must never cross the wire (in a
|
|
76
|
+
* row OR as a distinct-value projection). Accepts camelCase or snake_case.
|
|
77
|
+
*/
|
|
78
|
+
export declare function isSensitiveOutputKey(key: string): boolean;
|
|
74
79
|
/** Strip auth-secret columns from a row (or null) before it crosses the wire. */
|
|
75
80
|
export declare function redactSensitiveOutput<T>(row: T): T;
|
|
76
81
|
export declare function sanitizePayload(payload: Record<string, unknown>, columns: NucleusColumn[]): Record<string, unknown>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|