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.
@@ -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
- function extractSubFromJwt(token) {
151
- try {
152
- const parts = token.split('.');
153
- if (parts.length !== 3 || !parts[1]) return null;
154
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
155
- return typeof payload.sub === 'string' ? payload.sub : null;
156
- } catch {
157
- return null;
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
- const cookies = parseCookies(req.headers.get('cookie'));
347
- const token = cookies[target.injectUserIdFromJwt.cookieName];
348
- if (token) {
349
- const userId = extractSubFromJwt(token);
350
- if (userId) {
351
- headers.set(target.injectUserIdFromJwt.headerName ?? 'x-user-id', userId);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.730",
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",