nucleus-core-ts 0.9.730 → 0.9.732
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 +4 -4
- package/dist/src/Client/Proxy/httpProxy.d.ts +6 -0
- package/dist/src/Client/Proxy/httpProxy.js +26 -16
- package/dist/src/Client/Proxy/httpProxy.test.d.ts +1 -0
- package/dist/src/Client/Proxy/httpProxy.test.js +73 -0
- package/dist/src/Client/Proxy/types.d.ts +7 -0
- package/dist/src/ElysiaPlugin/routes/auth/accountState.d.ts +30 -0
- package/dist/src/ElysiaPlugin/routes/auth/accountState.test.d.ts +1 -0
- package/package.json +1 -1
|
@@ -8,6 +8,12 @@ export declare function deduplicatedRefresh(refreshConfig: TokenRefreshConfig, r
|
|
|
8
8
|
warn: (...args: unknown[]) => void;
|
|
9
9
|
error: (...args: unknown[]) => void;
|
|
10
10
|
}, originalCookieHeader?: string | null): Promise<RefreshResult | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
13
|
+
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
14
|
+
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveInjectedUserId(token: string | undefined, secret: string | undefined): string | null;
|
|
11
17
|
export declare function createHttpProxyHandler(config: HttpProxyConfig): {
|
|
12
18
|
handle(req: Request, clientIp?: string): Promise<Response | null>;
|
|
13
19
|
};
|
|
@@ -147,15 +147,14 @@ function appendSetCookieHeaders(responseHeaders, setCookieHeaders) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
152
|
+
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
153
|
+
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
154
|
+
*/ export function resolveInjectedUserId(token, secret) {
|
|
155
|
+
if (!secret || !token) return null;
|
|
156
|
+
const payload = verifyJwtHS256(token, secret);
|
|
157
|
+
return payload && typeof payload.sub === 'string' ? payload.sub : null;
|
|
159
158
|
}
|
|
160
159
|
export function createHttpProxyHandler(config) {
|
|
161
160
|
const logger = createProxyLogger('HTTP Proxy', config.debug ?? false);
|
|
@@ -343,13 +342,24 @@ export function createHttpProxyHandler(config) {
|
|
|
343
342
|
}
|
|
344
343
|
}
|
|
345
344
|
if (target.injectUserIdFromJwt) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
345
|
+
// x-user-id here is authoritative-from-the-VERIFIED-JWT. Two rules make that hold:
|
|
346
|
+
// 1. ALWAYS strip any inbound client-supplied value FIRST, so a caller can never spoof
|
|
347
|
+
// it (closes the fail-open when the cookie is absent or the token is malformed).
|
|
348
|
+
// 2. Re-set it ONLY from a cryptographically verified token (HMAC + exp via
|
|
349
|
+
// verifyJwtHS256). A bare base64 decode of `sub` is attacker-forgeable (alg:none),
|
|
350
|
+
// so trusting it would let anyone impersonate any user id.
|
|
351
|
+
const headerName = target.injectUserIdFromJwt.headerName ?? 'x-user-id';
|
|
352
|
+
headers.delete(headerName);
|
|
353
|
+
const verifySecret = target.injectUserIdFromJwt.secret ?? target.authorize?.jwt?.secret;
|
|
354
|
+
if (!verifySecret) {
|
|
355
|
+
// No secret to verify with → the token's sub cannot be trusted. Fail closed: inject
|
|
356
|
+
// nothing (the header was already stripped above).
|
|
357
|
+
logger.warn('[Proxy] injectUserIdFromJwt has no `secret` (and no authorize.jwt.secret) to verify the token; x-user-id NOT injected (fail-closed).');
|
|
358
|
+
} else {
|
|
359
|
+
const cookies = parseCookies(req.headers.get('cookie'));
|
|
360
|
+
const token = cookies[target.injectUserIdFromJwt.cookieName];
|
|
361
|
+
const sub = resolveInjectedUserId(token, verifySecret);
|
|
362
|
+
if (sub) headers.set(headerName, sub);
|
|
353
363
|
}
|
|
354
364
|
}
|
|
355
365
|
return headers;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import { resolveInjectedUserId } from './httpProxy';
|
|
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 = 'inject-secret';
|
|
15
|
+
describe('resolveInjectedUserId (x-user-id injection is verified, not decoded)', ()=>{
|
|
16
|
+
it('returns the sub of a correctly signed, unexpired token', ()=>{
|
|
17
|
+
const t = signHS256({
|
|
18
|
+
sub: 'user-123'
|
|
19
|
+
}, SECRET);
|
|
20
|
+
expect(resolveInjectedUserId(t, SECRET)).toBe('user-123');
|
|
21
|
+
});
|
|
22
|
+
it('returns null for a FORGED unsigned token (alg:none-style, arbitrary sig)', ()=>{
|
|
23
|
+
// The pre-fix code base64-decoded this and returned "victim" — an impersonation vector.
|
|
24
|
+
const forged = `${b64({
|
|
25
|
+
alg: 'none'
|
|
26
|
+
})}.${b64({
|
|
27
|
+
sub: 'victim'
|
|
28
|
+
})}.garbage`;
|
|
29
|
+
expect(resolveInjectedUserId(forged, SECRET)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('returns null for a token signed with a different secret', ()=>{
|
|
32
|
+
const t = signHS256({
|
|
33
|
+
sub: 'user-123'
|
|
34
|
+
}, 'other-secret');
|
|
35
|
+
expect(resolveInjectedUserId(t, SECRET)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('returns null for a tampered sub (re-encoded payload, original signature)', ()=>{
|
|
38
|
+
const t = signHS256({
|
|
39
|
+
sub: 'user-123'
|
|
40
|
+
}, SECRET);
|
|
41
|
+
const [h, , s] = t.split('.');
|
|
42
|
+
const tampered = `${h}.${b64({
|
|
43
|
+
sub: 'victim'
|
|
44
|
+
})}.${s}`;
|
|
45
|
+
expect(resolveInjectedUserId(tampered, SECRET)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('returns null for an expired token', ()=>{
|
|
48
|
+
const t = signHS256({
|
|
49
|
+
sub: 'user-123',
|
|
50
|
+
exp: Math.floor(Date.now() / 1000) - 60
|
|
51
|
+
}, SECRET);
|
|
52
|
+
expect(resolveInjectedUserId(t, SECRET)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
it('fails CLOSED when no secret is available (cannot verify → do not trust)', ()=>{
|
|
55
|
+
const t = signHS256({
|
|
56
|
+
sub: 'user-123'
|
|
57
|
+
}, SECRET);
|
|
58
|
+
expect(resolveInjectedUserId(t, undefined)).toBeNull();
|
|
59
|
+
expect(resolveInjectedUserId(t, '')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('returns null when there is no token', ()=>{
|
|
62
|
+
expect(resolveInjectedUserId(undefined, SECRET)).toBeNull();
|
|
63
|
+
expect(resolveInjectedUserId('', SECRET)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('returns null when the token has no string sub', ()=>{
|
|
66
|
+
expect(resolveInjectedUserId(signHS256({
|
|
67
|
+
foo: 'bar'
|
|
68
|
+
}, SECRET), SECRET)).toBeNull();
|
|
69
|
+
expect(resolveInjectedUserId(signHS256({
|
|
70
|
+
sub: 123
|
|
71
|
+
}, SECRET), SECRET)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -110,6 +110,13 @@ export interface HttpProxyTarget {
|
|
|
110
110
|
injectUserIdFromJwt?: {
|
|
111
111
|
cookieName: string;
|
|
112
112
|
headerName?: string;
|
|
113
|
+
/**
|
|
114
|
+
* HS256 secret used to VERIFY the token before trusting its `sub`. Without a secret (here
|
|
115
|
+
* or on `authorize.jwt.secret`) the sub can't be trusted, so x-user-id is NOT injected
|
|
116
|
+
* (fail-closed) — otherwise a forged/unsigned token could impersonate any user on a bare
|
|
117
|
+
* backend that trusts x-user-id. May be an env var name resolved by the caller.
|
|
118
|
+
*/
|
|
119
|
+
secret?: string;
|
|
113
120
|
};
|
|
114
121
|
/** Enforce discovered endpoint claims for this target at the proxy. */
|
|
115
122
|
authorize?: ProxyAuthorizeConfig;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, fail-closed account-state kill-switch for EVERY token-minting auth path
|
|
3
|
+
* (password login, OAuth callback, magic-link verify, WebAuthn, refresh). A locked or
|
|
4
|
+
* deactivated account must not be able to obtain a fresh access/refresh token or session
|
|
5
|
+
* through ANY path — otherwise an admin's lock/deactivation is trivially bypassed by simply
|
|
6
|
+
* logging in again via a different mechanism. Centralized here so the invariant can't drift
|
|
7
|
+
* out of sync between handlers again.
|
|
8
|
+
*
|
|
9
|
+
* Semantics mirror the canonical refresh check (routes/auth/refresh):
|
|
10
|
+
* - `isActive === false` → denied (admin deactivation). Absent/undefined isActive is NOT a
|
|
11
|
+
* denial, so deployments without the column keep working (fail-open only on absence).
|
|
12
|
+
* - `isLocked === true` AND the lock has NOT expired (`lockedUntil` null or in the future)
|
|
13
|
+
* → denied. A past `lockedUntil` means the lock lapsed and no longer blocks.
|
|
14
|
+
*
|
|
15
|
+
* Cohort-expiry is intentionally NOT handled here: it needs a separate table lookup and is
|
|
16
|
+
* enforced per-path where cohorts apply (login/refresh).
|
|
17
|
+
*/
|
|
18
|
+
export interface AccountStateFields {
|
|
19
|
+
isActive?: unknown;
|
|
20
|
+
isLocked?: unknown;
|
|
21
|
+
lockedUntil?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export type AccountStateResult = {
|
|
24
|
+
allowed: true;
|
|
25
|
+
} | {
|
|
26
|
+
allowed: false;
|
|
27
|
+
reason: 'inactive' | 'locked';
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function evaluateAccountState(user: AccountStateFields, now?: Date): AccountStateResult;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.732",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|