@dszp/netsapiens-lib 0.1.9 → 0.3.0
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/README.md +95 -1
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +151 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +0 -0
- package/dist/inventory.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js.map +1 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/model.d.ts +18 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js.map +1 -0
- package/dist/nsAuthClient.d.ts.map +1 -0
- package/dist/nsAuthClient.js.map +1 -0
- package/dist/nsClient.d.ts +19 -0
- package/dist/nsClient.d.ts.map +1 -0
- package/dist/nsClient.js +40 -3
- package/dist/nsClient.js.map +1 -0
- package/dist/nsDevice.d.ts.map +1 -0
- package/dist/nsDevice.js.map +1 -0
- package/dist/nsSubscriptions.d.ts.map +1 -0
- package/dist/nsSubscriptions.js.map +1 -0
- package/dist/nsSynchronous.d.ts.map +1 -0
- package/dist/nsSynchronous.js.map +1 -0
- package/dist/nsWriteClient.d.ts.map +1 -0
- package/dist/nsWriteClient.js.map +1 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js.map +1 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js.map +1 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js.map +1 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js.map +1 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js.map +1 -0
- package/package.json +7 -3
- package/src/eligibility.selftest.ts +95 -0
- package/src/eligibility.ts +118 -0
- package/src/html.ts +407 -0
- package/src/index.ts +120 -0
- package/src/inventory.selftest.ts +198 -0
- package/src/inventory.ts +314 -0
- package/src/jwt.selftest.ts +145 -0
- package/src/jwt.ts +491 -0
- package/src/mermaid.ts +169 -0
- package/src/model.ts +130 -0
- package/src/nsAuthClient.selftest.ts +60 -0
- package/src/nsAuthClient.ts +102 -0
- package/src/nsClient.selftest.ts +173 -0
- package/src/nsClient.ts +323 -0
- package/src/nsDevice.selftest.ts +190 -0
- package/src/nsDevice.ts +167 -0
- package/src/nsSubscriptions.selftest.ts +486 -0
- package/src/nsSubscriptions.ts +638 -0
- package/src/nsSynchronous.selftest.ts +63 -0
- package/src/nsSynchronous.ts +98 -0
- package/src/nsWriteClient.selftest.ts +104 -0
- package/src/nsWriteClient.ts +157 -0
- package/src/policy.ts +123 -0
- package/src/principal.selftest.ts +118 -0
- package/src/principal.ts +101 -0
- package/src/raster.selftest.ts +42 -0
- package/src/raster.ts +79 -0
- package/src/resolver.selftest.ts +225 -0
- package/src/resolver.ts +1115 -0
- package/src/sensitivity.ts +40 -0
- package/src/themes.ts +142 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runnable self-test for the portable JWT module (no test framework needed):
|
|
3
|
+
* npm run test:jwt (or: tsx src/jwt.selftest.ts)
|
|
4
|
+
*
|
|
5
|
+
* Crafts synthetic tokens (test-side base64url via Buffer is fine — the module under test is
|
|
6
|
+
* Buffer-free) and a mock fetch, then asserts the local gate, context extraction, live check,
|
|
7
|
+
* and the cache behavior that keeps the NS API from being hammered.
|
|
8
|
+
*/
|
|
9
|
+
import { createHmac } from 'node:crypto';
|
|
10
|
+
import { validateJwtFormat, verify, assertClaims, MemoryVerdictCache, tokenKey } from './jwt.js';
|
|
11
|
+
|
|
12
|
+
const b64url = (o: unknown) =>
|
|
13
|
+
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
14
|
+
const mk = (payload: Record<string, unknown>) => `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`;
|
|
15
|
+
/** HS256-sign a token with a real HMAC (test-side node crypto; the module under test stays portable). */
|
|
16
|
+
const sign = (payload: Record<string, unknown>, secret: string) => {
|
|
17
|
+
const h = b64url({ alg: 'HS256', typ: 'JWT' });
|
|
18
|
+
const p = b64url(payload);
|
|
19
|
+
const s = createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url');
|
|
20
|
+
return `${h}.${p}.${s}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const now = Date.UTC(2026, 6, 10, 12, 0, 0);
|
|
24
|
+
const ISS = 'manage.example.com';
|
|
25
|
+
const AUD_ISS = { aud: 'ns', iss: ISS };
|
|
26
|
+
const future = mk({ ...AUD_ISS, sub: '9000@acme.12345.service', user_scope: 'Office Manager', exp: Math.floor(now / 1000) + 3600, name_u: 'Ünïçödé' });
|
|
27
|
+
const past = mk({ ...AUD_ISS, sub: '100@acme.12345.service', exp: Math.floor(now / 1000) - 10 });
|
|
28
|
+
|
|
29
|
+
let pass = 0;
|
|
30
|
+
let fail = 0;
|
|
31
|
+
const ok = (c: boolean, m: string) => {
|
|
32
|
+
c ? pass++ : fail++;
|
|
33
|
+
console.log(`${c ? '✓' : '✗ FAIL'} ${m}`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
(async () => {
|
|
37
|
+
const f = validateJwtFormat(future, now);
|
|
38
|
+
ok(f.validFormat && f.unexpired, 'format: valid & unexpired');
|
|
39
|
+
ok(f.context.domain === 'acme.12345.service' && f.context.user === '9000' && f.context.scope === 'Office Manager', 'format: domain/user/scope from sub+claim');
|
|
40
|
+
ok((f.payload as Record<string, unknown>)['name_u'] === 'Ünïçödé', 'format: UTF-8 payload decodes (no Buffer)');
|
|
41
|
+
ok(!validateJwtFormat(past, now).unexpired, 'format: expired token flagged');
|
|
42
|
+
ok(!validateJwtFormat('a.b', now).validFormat, 'format: 2-segment token rejected');
|
|
43
|
+
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const fetchImpl = (async () => {
|
|
46
|
+
calls++;
|
|
47
|
+
return { status: 200, ok: true };
|
|
48
|
+
}) as unknown as typeof fetch;
|
|
49
|
+
const cache = new MemoryVerdictCache();
|
|
50
|
+
const v1 = await verify(future, { server: 'api.example.com', mode: 'live', expectedIss: ISS, cache, fetchImpl, nowMs: now });
|
|
51
|
+
ok(v1.ok && v1.live === 'valid' && !v1.fromCache && calls === 1, 'live: first call hits server, valid');
|
|
52
|
+
const v2 = await verify(future, { server: 'api.example.com', mode: 'live', expectedIss: ISS, cache, fetchImpl, nowMs: now });
|
|
53
|
+
ok(v2.ok && v2.fromCache === true && calls === 1, 'live: second call from cache (no extra roundtrip)');
|
|
54
|
+
// The persisted verdict must NOT carry the full decoded claims blob (PII): nothing downstream reads
|
|
55
|
+
// verdict.payload (toPrincipal uses the typed context fields), so it's trimmed before caching.
|
|
56
|
+
const stored = await cache.get(await tokenKey(future, 'api.example.com'));
|
|
57
|
+
ok(!!stored && !('payload' in stored), 'cache: decoded claims payload NOT persisted (PII trim)');
|
|
58
|
+
ok(!('payload' in v2), 'cache: verdict served from cache carries no raw payload');
|
|
59
|
+
|
|
60
|
+
let calls2 = 0;
|
|
61
|
+
const bad = (async () => {
|
|
62
|
+
calls2++;
|
|
63
|
+
return { status: 401, ok: false };
|
|
64
|
+
}) as unknown as typeof fetch;
|
|
65
|
+
const c2 = new MemoryVerdictCache();
|
|
66
|
+
const b1 = await verify(future, { server: 'x', mode: 'live', expectedIss: ISS, cache: c2, fetchImpl: bad, nowMs: now });
|
|
67
|
+
const b2 = await verify(future, { server: 'x', mode: 'live', expectedIss: ISS, cache: c2, fetchImpl: bad, nowMs: now });
|
|
68
|
+
ok(!b1.ok && b1.live === 'invalid' && b1.statusCode === 401, 'live: 401 → invalid');
|
|
69
|
+
ok(b2.fromCache === true && calls2 === 1, 'live: negative verdict cached');
|
|
70
|
+
|
|
71
|
+
let calls3 = 0;
|
|
72
|
+
const spy = (async () => {
|
|
73
|
+
calls3++;
|
|
74
|
+
return { status: 200, ok: true };
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
const e = await verify(past, { server: 'x', mode: 'live', expectedIss: ISS, fetchImpl: spy, nowMs: now });
|
|
77
|
+
ok(!e.ok && calls3 === 0, 'gate: expired token short-circuits before roundtrip');
|
|
78
|
+
|
|
79
|
+
// forceFresh: a logged-out/revoked token still passes from a stale cached verdict, but a
|
|
80
|
+
// force-fresh check (writes / sensitive reads) re-hits the server AND overwrites the stale entry.
|
|
81
|
+
let ffStatus = 200;
|
|
82
|
+
let ffCalls = 0;
|
|
83
|
+
const ffFetch = (async () => {
|
|
84
|
+
ffCalls++;
|
|
85
|
+
return { status: ffStatus, ok: ffStatus < 400 };
|
|
86
|
+
}) as unknown as typeof fetch;
|
|
87
|
+
const c3 = new MemoryVerdictCache();
|
|
88
|
+
const ffOpt = { server: 'x', mode: 'live' as const, expectedIss: ISS, cache: c3, fetchImpl: ffFetch, nowMs: now };
|
|
89
|
+
const f1 = await verify(future, ffOpt);
|
|
90
|
+
ok(f1.ok && ffCalls === 1, 'forceFresh: initial live check valid + cached');
|
|
91
|
+
ffStatus = 401; // token now logged out server-side
|
|
92
|
+
const f2 = await verify(future, ffOpt);
|
|
93
|
+
ok(f2.ok && f2.fromCache === true && ffCalls === 1, 'forceFresh: stale cached verdict still passes (the gap)');
|
|
94
|
+
const f3 = await verify(future, { ...ffOpt, forceFresh: true });
|
|
95
|
+
ok(!f3.ok && f3.live === 'invalid' && !f3.fromCache && ffCalls === 2, 'forceFresh: bypasses cache, catches revocation');
|
|
96
|
+
const f4 = await verify(future, ffOpt);
|
|
97
|
+
ok(!f4.ok && f4.fromCache === true && ffCalls === 2, 'forceFresh: overwrote cache → later cheap reads reject too');
|
|
98
|
+
|
|
99
|
+
// aud / iss assertions — pure claim check (independent of signature/roundtrip).
|
|
100
|
+
ok(assertClaims({ aud: 'ns', iss: ISS }, { iss: ISS }).ok, 'claims: aud=ns + matching iss pass');
|
|
101
|
+
ok(!assertClaims({ aud: 'notns', iss: ISS }, { iss: ISS }).ok, 'claims: wrong aud rejected (default aud=ns)');
|
|
102
|
+
ok(!assertClaims({ aud: 'ns', iss: 'evil.example.com' }, { iss: ISS }).ok, 'claims: wrong iss rejected');
|
|
103
|
+
// `iss` has NO default: a default would be one specific portal, silently binding every consumer to
|
|
104
|
+
// it. Omitting the expectation must FAIL CLOSED, not skip the check.
|
|
105
|
+
const noIssExp = assertClaims({ aud: 'ns', iss: ISS });
|
|
106
|
+
ok(!noIssExp.ok && /iss expectation missing/.test(noIssExp.reason ?? ''),
|
|
107
|
+
'claims: no iss expectation ⇒ fails CLOSED (no default issuer)');
|
|
108
|
+
|
|
109
|
+
// Several portal hostnames can front the same backend (a branded host + the vendor's unbranded one),
|
|
110
|
+
// so `iss` takes a list, mirroring `aud`. Exact match only — no wildcards.
|
|
111
|
+
const MULTI = ['manage.example.com', 'manage.vendor.example'];
|
|
112
|
+
ok(assertClaims({ aud: 'ns', iss: 'manage.example.com' }, { iss: MULTI }).ok, 'claims: iss list — first issuer accepted');
|
|
113
|
+
ok(assertClaims({ aud: 'ns', iss: 'manage.vendor.example' }, { iss: MULTI }).ok, 'claims: iss list — second issuer accepted');
|
|
114
|
+
ok(!assertClaims({ aud: 'ns', iss: 'evil.example.com' }, { iss: MULTI }).ok, 'claims: iss list — an unlisted issuer is still rejected');
|
|
115
|
+
ok(!assertClaims({ aud: 'ns', iss: 'evil.vendor.example' }, { iss: ['*.vendor.example'] }).ok,
|
|
116
|
+
'claims: NO wildcard matching — "*.vendor.example" is a literal, not a pattern');
|
|
117
|
+
ok(!assertClaims({ aud: 'ns', iss: ISS }, { iss: [] }).ok, 'claims: empty iss list ⇒ fails CLOSED (not "allow any")');
|
|
118
|
+
ok(!assertClaims({ aud: 'ns', iss: ISS }, { iss: [' ', ''] }).ok, 'claims: blank-only iss list ⇒ fails CLOSED');
|
|
119
|
+
ok(assertClaims({ aud: 'ns', iss: 'evil.example.com' }, { validateIss: false }).ok, 'claims: validateIss:false lets a foreign issuer through');
|
|
120
|
+
ok(assertClaims({ aud: ['ns', 'other'] }, { validateIss: false }).ok, 'claims: aud as array accepted');
|
|
121
|
+
|
|
122
|
+
// verify() rejects wrong aud locally, before any roundtrip (reason carries the cause).
|
|
123
|
+
const fmtOpt = { server: 'x', mode: 'format' as const, expectedIss: ISS, nowMs: now };
|
|
124
|
+
const badAud = mk({ aud: 'notns', iss: 'manage.example.com', sub: 'a@b', exp: Math.floor(now / 1000) + 3600 });
|
|
125
|
+
const rAud = await verify(badAud, fmtOpt);
|
|
126
|
+
ok(!rAud.ok && /aud mismatch/.test(rAud.reason ?? ''), 'verify: wrong aud rejected locally (no roundtrip)');
|
|
127
|
+
|
|
128
|
+
// #4: format-mode `ok` requires a LOCALLY-VERIFIED signature (else 'unverified' ⇒ NOT ok).
|
|
129
|
+
const SECRET = 'test-ns-shared-secret';
|
|
130
|
+
const goodClaims = { ...AUD_ISS, sub: '100@acme', exp: Math.floor(now / 1000) + 3600 };
|
|
131
|
+
const signed = sign(goodClaims, SECRET);
|
|
132
|
+
const sv = await verify(signed, { ...fmtOpt, signingSecret: SECRET });
|
|
133
|
+
ok(sv.ok && sv.signature === 'valid', 'format: ok ONLY with a verified signature');
|
|
134
|
+
const noSecret = await verify(future, fmtOpt);
|
|
135
|
+
ok(!noSecret.ok && noSecret.signature === 'unverified', 'format: no secret ⇒ NOT ok (unverified — use mode:live to authenticate)');
|
|
136
|
+
const swrong = await verify(signed, { ...fmtOpt, signingSecret: 'wrong-secret' });
|
|
137
|
+
ok(!swrong.ok && swrong.signature === 'invalid', 'sig: wrong secret rejected');
|
|
138
|
+
const tampered = `${signed.split('.')[0]}.${b64url({ ...goodClaims, sub: 'attacker@evil' })}.${signed.split('.')[2]}`;
|
|
139
|
+
ok(!(await verify(tampered, { ...fmtOpt, signingSecret: SECRET })).ok, 'sig: tampered payload rejected');
|
|
140
|
+
const algNone = `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(goodClaims)}.`;
|
|
141
|
+
ok(!(await verify(algNone, { ...fmtOpt, signingSecret: SECRET })).ok, 'sig: alg:none rejected when a secret is set');
|
|
142
|
+
|
|
143
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
144
|
+
process.exit(fail ? 1 : 0);
|
|
145
|
+
})();
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSapiens `ns_t` JWT validation — runtime-portable (Cloudflare Worker + Node 18+).
|
|
3
|
+
*
|
|
4
|
+
* Ported from n8n-nodes-netsapiens (nodes/NetSapiens/NetSapiens.node.ts), which exposes two
|
|
5
|
+
* operations we mirror here:
|
|
6
|
+
* - Validate JWT Format → local base64url-decode + `exp` check, NO server contact, NO
|
|
7
|
+
* signature verification (low-security; matches the node exactly).
|
|
8
|
+
* - Validate JWT (live) → GET {server}/ns-api/v2/jwt with `Authorization: Bearer <token>`;
|
|
9
|
+
* HTTP 401/403 ⇒ rejected. Authoritative, but a server roundtrip.
|
|
10
|
+
*
|
|
11
|
+
* Design constraint (David): the live path must NOT overload the NS API. So `verify()` gates on
|
|
12
|
+
* the cheap local check first, then serves a *cached* live verdict keyed by a hash of the token,
|
|
13
|
+
* with a TTL capped by the token's own `exp`. A bad or expired token never reaches the server.
|
|
14
|
+
*
|
|
15
|
+
* Uses only Web-standard globals (atob, TextDecoder, crypto.subtle, fetch) — no Node Buffer — so
|
|
16
|
+
* the same file runs in a Worker and in an onboarding CLI.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { assertBareServer } from './nsClient.js';
|
|
20
|
+
|
|
21
|
+
export interface JwtContext {
|
|
22
|
+
/** The token's domain (from claims). Scopes downstream NS reads. When masking, this is the
|
|
23
|
+
* MASKED user's domain — the token is NS-scoped to it, not the operator's. */
|
|
24
|
+
domain?: string;
|
|
25
|
+
/** The token's user / extension (the masked user, when masking). */
|
|
26
|
+
user?: string;
|
|
27
|
+
/** user_scope / scope claim if present (Reseller / Office Manager / Basic User / …). When masking
|
|
28
|
+
* this is the MASKED user's scope, NOT the operator's — so don't infer "a reseller drives this"
|
|
29
|
+
* from scope alone; consult `maskChain`. */
|
|
30
|
+
scope?: string;
|
|
31
|
+
/** sub claim (typically user@domain — the effective/masked identity). */
|
|
32
|
+
sub?: string;
|
|
33
|
+
/** `mask_chain` claim: absent/null ⇒ NOT masking; `"user@domain"` ⇒ the REAL operator behind the
|
|
34
|
+
* mask (e.g. the reseller impersonating a domain user). The masking flag + operator identity. */
|
|
35
|
+
maskChain?: string;
|
|
36
|
+
/** user_email claim. */
|
|
37
|
+
email?: string;
|
|
38
|
+
/** displayName claim. */
|
|
39
|
+
displayName?: string;
|
|
40
|
+
/** territory claim. */
|
|
41
|
+
territory?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JwtVerdict extends JwtContext {
|
|
45
|
+
/** Structurally a JWT (3 segments, decodable payload). */
|
|
46
|
+
validFormat: boolean;
|
|
47
|
+
/** `exp` claim exists and is in the future. */
|
|
48
|
+
unexpired: boolean;
|
|
49
|
+
/** Result of the live server check, when performed. */
|
|
50
|
+
live: 'valid' | 'invalid' | 'skipped' | 'error';
|
|
51
|
+
/** Local HS256 signature check: 'valid'/'invalid' when a signingSecret is configured, else
|
|
52
|
+
* 'unverified' (no local key — the live `/jwt` check is the signature authority). */
|
|
53
|
+
signature?: 'valid' | 'invalid' | 'unverified';
|
|
54
|
+
/** Overall gate: safe to proceed. */
|
|
55
|
+
ok: boolean;
|
|
56
|
+
expiresAt?: string;
|
|
57
|
+
expiresInSeconds?: number;
|
|
58
|
+
reason?: string;
|
|
59
|
+
statusCode?: number;
|
|
60
|
+
checkedAt: string;
|
|
61
|
+
fromCache?: boolean;
|
|
62
|
+
/** Full decoded payload (local decode). */
|
|
63
|
+
payload?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Local decode (base64url) — Buffer-free
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Decode a base64url string to UTF-8 without Node Buffer. */
|
|
71
|
+
function base64urlToUtf8(b64url: string): string {
|
|
72
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
73
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
74
|
+
const binary = atob(padded);
|
|
75
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
76
|
+
return new TextDecoder().decode(bytes);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Strip a leading "Bearer " and whitespace. */
|
|
80
|
+
export function normalizeToken(raw: string): string {
|
|
81
|
+
return String(raw ?? '')
|
|
82
|
+
.replace(/^Bearer\s+/i, '')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** base64url → bytes (no Node Buffer). Explicit `new Uint8Array(len)` so it's ArrayBuffer-backed
|
|
87
|
+
* (a plain BufferSource for crypto.subtle), not `Uint8Array<ArrayBufferLike>`. */
|
|
88
|
+
function base64urlToBytes(b64url: string) {
|
|
89
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
90
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
91
|
+
const binary = atob(padded);
|
|
92
|
+
const out = new Uint8Array(binary.length); // inferred Uint8Array<ArrayBuffer> — a plain BufferSource
|
|
93
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Assert the ns_t audience (and, by default, issuer). `aud` defaults to "ns" — that value is fixed by
|
|
99
|
+
* the NetSapiens platform, true for every deployment — and is ALWAYS checked.
|
|
100
|
+
*
|
|
101
|
+
* `iss` is your Manager Portal host, which is deployment-specific, so it has **no default**: you must
|
|
102
|
+
* either pass `iss` or explicitly opt out with `validateIss: false`. Omitting both fails closed rather
|
|
103
|
+
* than silently skipping the check. (Earlier versions defaulted to one specific portal host, which
|
|
104
|
+
* quietly bound every consumer to someone else's deployment — a bug, not a convenience.)
|
|
105
|
+
*
|
|
106
|
+
* `iss` accepts a LIST, mirroring `aud`: several portal hostnames can front the same backend (a
|
|
107
|
+
* white-labelled host and the vendor's unbranded one), and a token minted by either is equally valid.
|
|
108
|
+
* Matching is an **exact, case-sensitive** string compare against an explicit list — no wildcards, no
|
|
109
|
+
* suffix matching. `["manage.example.com", "manage.vendor.example"]` is allowed; `"*.vendor.example"`
|
|
110
|
+
* is not, and would be treated as a literal hostname that never matches.
|
|
111
|
+
*
|
|
112
|
+
* Pure claim comparison — no key needed, no network.
|
|
113
|
+
*/
|
|
114
|
+
export interface ClaimExpectations {
|
|
115
|
+
/** Required audience — default "ns". Token `aud` must equal (or, if array, include) one of these. */
|
|
116
|
+
aud?: string | string[];
|
|
117
|
+
/** Required issuer(s) — YOUR portal host(s), e.g. "manage.example.com" or
|
|
118
|
+
* ["manage.example.com", "manage.vendor.example"]. Token `iss` must EXACTLY equal one of them.
|
|
119
|
+
* No default: required unless `validateIss: false`. */
|
|
120
|
+
iss?: string | string[];
|
|
121
|
+
/** Set false to SKIP issuer validation (e.g. accepting tokens across portal domains). Default true,
|
|
122
|
+
* which makes `iss` mandatory. */
|
|
123
|
+
validateIss?: boolean;
|
|
124
|
+
}
|
|
125
|
+
export function assertClaims(payload: Record<string, unknown>, exp: ClaimExpectations = {}): { ok: boolean; reason?: string } {
|
|
126
|
+
const wantAud = exp.aud ?? 'ns';
|
|
127
|
+
const wanted = Array.isArray(wantAud) ? wantAud : [wantAud];
|
|
128
|
+
const rawAud = payload.aud;
|
|
129
|
+
const got = Array.isArray(rawAud) ? rawAud.map(String) : rawAud != null ? [String(rawAud)] : [];
|
|
130
|
+
if (!got.some((a) => wanted.includes(a))) {
|
|
131
|
+
return { ok: false, reason: `aud mismatch (want ${wanted.join('|')}, got ${got.join('|') || '∅'})` };
|
|
132
|
+
}
|
|
133
|
+
if (exp.validateIss !== false) {
|
|
134
|
+
// Accept one issuer or several (same backend behind more than one portal hostname). Exact match
|
|
135
|
+
// only — a wildcard here would let any host under a suffix mint tokens we accept.
|
|
136
|
+
const rawIss = exp.iss;
|
|
137
|
+
const wantedIss = (Array.isArray(rawIss) ? rawIss : rawIss != null ? [rawIss] : []).map((i) => String(i).trim()).filter(Boolean);
|
|
138
|
+
// No default: an issuer default would be someone's specific portal. Fail closed and say how to fix.
|
|
139
|
+
if (!wantedIss.length) {
|
|
140
|
+
return { ok: false, reason: 'iss expectation missing — pass `iss` (your portal host, e.g. "manage.example.com", or a list of them) or set `validateIss: false` to opt out' };
|
|
141
|
+
}
|
|
142
|
+
const gotIss = String(payload.iss ?? '');
|
|
143
|
+
if (!wantedIss.includes(gotIss)) {
|
|
144
|
+
return { ok: false, reason: `iss mismatch (want ${wantedIss.join('|')}, got ${gotIss || '∅'})` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Verify an ns_t's HS256 signature locally with the shared secret. ns_t is HS256 (symmetric), signed
|
|
152
|
+
* by the NetSapiens core — so this needs that HMAC secret (there is no public JWKS). Returns false for
|
|
153
|
+
* a wrong/absent secret, tampered token, or any header alg other than HS256 (blocks alg:none / alg
|
|
154
|
+
* confusion). Async (crypto.subtle HMAC). When you don't hold the secret, leave it unset and rely on
|
|
155
|
+
* the live `/jwt` roundtrip as the (server-side) signature authority.
|
|
156
|
+
*/
|
|
157
|
+
export async function verifyHs256Signature(token: string, secret: string): Promise<boolean> {
|
|
158
|
+
const parts = normalizeToken(token).split('.');
|
|
159
|
+
if (parts.length !== 3 || !secret) return false;
|
|
160
|
+
try {
|
|
161
|
+
const header = JSON.parse(base64urlToUtf8(parts[0]!)) as { alg?: string };
|
|
162
|
+
if (header.alg !== 'HS256') return false;
|
|
163
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
164
|
+
return await crypto.subtle.verify('HMAC', key, base64urlToBytes(parts[2]!), new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** exp/nbf claims may be number or numeric string (matches the node's toEpochSeconds). */
|
|
171
|
+
function toEpochSeconds(value: unknown): number | undefined {
|
|
172
|
+
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
|
|
173
|
+
if (typeof value === 'string') {
|
|
174
|
+
const n = Number.parseInt(value.trim(), 10);
|
|
175
|
+
if (Number.isFinite(n)) return n;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Pull the routing-relevant context out of NS claims (tolerant of naming variants). */
|
|
181
|
+
export function extractContext(payload: Record<string, unknown>): JwtContext {
|
|
182
|
+
const pick = (...keys: string[]): string | undefined => {
|
|
183
|
+
for (const k of keys) {
|
|
184
|
+
const v = payload[k];
|
|
185
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
};
|
|
189
|
+
const sub = pick('sub', 'username', 'user_name');
|
|
190
|
+
let domain = pick('domain', 'nsDomain', 'territory_domain');
|
|
191
|
+
let user = pick('user', 'uid', 'extension');
|
|
192
|
+
// sub is often user@domain — derive the halves if the explicit claims are absent.
|
|
193
|
+
if (sub && sub.includes('@')) {
|
|
194
|
+
const [u, d] = sub.split('@');
|
|
195
|
+
user = user ?? u;
|
|
196
|
+
domain = domain ?? d;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
domain,
|
|
200
|
+
user,
|
|
201
|
+
scope: pick('user_scope', 'scope', 'role'),
|
|
202
|
+
sub,
|
|
203
|
+
maskChain: pick('mask_chain', 'maskChain'),
|
|
204
|
+
email: pick('user_email', 'email'),
|
|
205
|
+
displayName: pick('displayName', 'display_name', 'name'),
|
|
206
|
+
territory: pick('territory'),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface FormatResult {
|
|
211
|
+
validFormat: boolean;
|
|
212
|
+
unexpired: boolean;
|
|
213
|
+
/** `nbf` is set and still in the future (beyond the skew leeway) ⇒ the token is not yet valid. */
|
|
214
|
+
notYetValid?: boolean;
|
|
215
|
+
expiresAt?: string;
|
|
216
|
+
expiresInSeconds?: number;
|
|
217
|
+
reason?: string;
|
|
218
|
+
payload?: Record<string, unknown>;
|
|
219
|
+
context: JwtContext;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Clock-skew leeway for `nbf`, in seconds. Matches the Cloudflare-Access verifier (access.ts) so
|
|
223
|
+
* the two JWT paths treat "not yet valid" identically and a small clock drift can't lock anyone out. */
|
|
224
|
+
const NBF_LEEWAY_SECONDS = 60;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Local format + expiry check. No network, no signature verification.
|
|
228
|
+
* `nowMs` is injectable for testing.
|
|
229
|
+
*/
|
|
230
|
+
export function validateJwtFormat(token: string, nowMs: number = Date.now()): FormatResult {
|
|
231
|
+
const t = normalizeToken(token);
|
|
232
|
+
if (!t) return { validFormat: false, unexpired: false, reason: 'Empty token', context: {} };
|
|
233
|
+
|
|
234
|
+
const parts = t.split('.');
|
|
235
|
+
if (parts.length !== 3) {
|
|
236
|
+
return { validFormat: false, unexpired: false, reason: `Expected 3 JWT segments, got ${parts.length}`, context: {} };
|
|
237
|
+
}
|
|
238
|
+
let payload: Record<string, unknown>;
|
|
239
|
+
try {
|
|
240
|
+
payload = JSON.parse(base64urlToUtf8(parts[1]!)) as Record<string, unknown>;
|
|
241
|
+
} catch {
|
|
242
|
+
return { validFormat: false, unexpired: false, reason: 'Failed to decode JWT payload (invalid base64url or JSON)', context: {} };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const expSeconds = toEpochSeconds(payload.exp);
|
|
246
|
+
const nowSeconds = Math.trunc(nowMs / 1000);
|
|
247
|
+
const context = extractContext(payload);
|
|
248
|
+
|
|
249
|
+
if (expSeconds === undefined) {
|
|
250
|
+
return { validFormat: true, unexpired: false, reason: 'Missing or invalid exp claim', payload, context };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// nbf: a token dated in the future is not yet valid. Without this it passes every local check and
|
|
254
|
+
// still triggers an upstream /jwt roundtrip a local gate should have refused; and format-mode
|
|
255
|
+
// callers (verify(..., {mode:'format', signingSecret})) would return ok:true for a not-yet-valid
|
|
256
|
+
// token. Defense-in-depth for the live path (the server is the real authority), authoritative for
|
|
257
|
+
// format mode. Leeway matches access.ts.
|
|
258
|
+
const nbfSeconds = toEpochSeconds(payload.nbf);
|
|
259
|
+
if (nbfSeconds !== undefined && nbfSeconds > nowSeconds + NBF_LEEWAY_SECONDS) {
|
|
260
|
+
return {
|
|
261
|
+
validFormat: true,
|
|
262
|
+
unexpired: expSeconds - nowSeconds > 0,
|
|
263
|
+
notYetValid: true,
|
|
264
|
+
expiresAt: new Date(expSeconds * 1000).toISOString(),
|
|
265
|
+
expiresInSeconds: expSeconds - nowSeconds,
|
|
266
|
+
reason: 'Token not yet valid (nbf is in the future)',
|
|
267
|
+
payload,
|
|
268
|
+
context,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const expiresInSeconds = expSeconds - nowSeconds;
|
|
273
|
+
return {
|
|
274
|
+
validFormat: true,
|
|
275
|
+
unexpired: expiresInSeconds > 0,
|
|
276
|
+
expiresAt: new Date(expSeconds * 1000).toISOString(),
|
|
277
|
+
expiresInSeconds,
|
|
278
|
+
...(expiresInSeconds <= 0 ? { reason: 'Token exp is in the past' } : {}),
|
|
279
|
+
payload,
|
|
280
|
+
context,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Live check + cache-aware verify
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** Pluggable verdict cache (back with Workers Cache API / KV / DO / memory). */
|
|
289
|
+
export interface VerdictCache {
|
|
290
|
+
get(key: string): Promise<JwtVerdict | undefined>;
|
|
291
|
+
/** ttlSeconds is a hint; the store may evict earlier. */
|
|
292
|
+
set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates).
|
|
297
|
+
*
|
|
298
|
+
* BOUNDED ON PURPOSE. Expiry alone is not a bound: entries expire lazily, on a `get()` for that
|
|
299
|
+
* exact key, so an attacker who uses each token once never triggers a sweep and every verdict is
|
|
300
|
+
* retained until the isolate dies. Since a *negative* verdict is cached for a token anyone can mint
|
|
301
|
+
* (correct `aud`/`iss`/`exp` need no signing key), an unbounded map is a remote OOM. Hence: sweep on
|
|
302
|
+
* insert, and hard-cap with FIFO eviction. `maxEntries` is generous — a real portal's working set is
|
|
303
|
+
* its live sessions, far below the cap, so eviction only ever bites the pathological case.
|
|
304
|
+
*/
|
|
305
|
+
export class MemoryVerdictCache implements VerdictCache {
|
|
306
|
+
private store = new Map<string, { verdict: JwtVerdict; expiresAtMs: number }>();
|
|
307
|
+
constructor(private readonly maxEntries = 1000) {}
|
|
308
|
+
async get(key: string): Promise<JwtVerdict | undefined> {
|
|
309
|
+
const hit = this.store.get(key);
|
|
310
|
+
if (!hit) return undefined;
|
|
311
|
+
if (hit.expiresAtMs <= Date.now()) {
|
|
312
|
+
this.store.delete(key);
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
return hit.verdict;
|
|
316
|
+
}
|
|
317
|
+
async set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void> {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
for (const [k, v] of this.store) if (v.expiresAtMs <= now) this.store.delete(k);
|
|
320
|
+
this.store.delete(key); // re-insert so Map iteration order == insertion order == eviction order
|
|
321
|
+
this.store.set(key, { verdict, expiresAtMs: now + ttlSeconds * 1000 });
|
|
322
|
+
while (this.store.size > this.maxEntries) {
|
|
323
|
+
const oldest = this.store.keys().next();
|
|
324
|
+
if (oldest.done) break;
|
|
325
|
+
this.store.delete(oldest.value);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Live entry count. Exposed for tests/observability; not part of the VerdictCache contract. */
|
|
329
|
+
get size(): number {
|
|
330
|
+
return this.store.size;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** SHA-256 hex of the token — cache key that never stores the raw token. */
|
|
335
|
+
export async function tokenKey(token: string, server?: string): Promise<string> {
|
|
336
|
+
// Scope the key by `server` when given: a consumer that fronts two NS cores with ONE cache (a
|
|
337
|
+
// reseller tool, or prod+staging sharing a KV namespace) would otherwise serve a token validated
|
|
338
|
+
// against server A as ok:true for a request bound to server B — B never contacted. verify() passes
|
|
339
|
+
// its server; the NUL separator can't appear in a hostname, so the two fields can't collide.
|
|
340
|
+
const material = server ? `${server}\u0000${normalizeToken(token)}` : normalizeToken(token);
|
|
341
|
+
const data = new TextEncoder().encode(material);
|
|
342
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
343
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface VerifyOptions {
|
|
347
|
+
/** NS API host, e.g. "api.example.com". */
|
|
348
|
+
server: string;
|
|
349
|
+
/** 'format' = local only (no roundtrip). 'live' = local gate + cached server check. */
|
|
350
|
+
mode?: 'format' | 'live';
|
|
351
|
+
cache?: VerdictCache;
|
|
352
|
+
/** Max seconds to trust a cached live verdict (also capped by the token's exp). Default 60. */
|
|
353
|
+
maxLiveTtlSeconds?: number;
|
|
354
|
+
/** How long to cache a negative live verdict. Default 30. */
|
|
355
|
+
negativeTtlSeconds?: number;
|
|
356
|
+
/** Abort the live `/jwt` fetch after this many ms (→ live:'error', fail closed). Default 4000. */
|
|
357
|
+
timeoutMs?: number;
|
|
358
|
+
/**
|
|
359
|
+
* Bypass the cache READ and always do the live server check — for writes / sensitive reads. The
|
|
360
|
+
* cache can serve a stale "valid" verdict for a token that has since been logged out / revoked
|
|
361
|
+
* (we get no logout event to evict it); force-fresh closes that window. The fresh verdict is still
|
|
362
|
+
* written back, so it OVERWRITES a stale entry (a now-invalid token's cached "valid" becomes
|
|
363
|
+
* "invalid"). Use it on the operations where ≤`maxLiveTtlSeconds` of staleness is unacceptable.
|
|
364
|
+
*/
|
|
365
|
+
forceFresh?: boolean;
|
|
366
|
+
/** Audience to require — default "ns". Always enforced locally (cheap, before any roundtrip). */
|
|
367
|
+
expectedAud?: string | string[];
|
|
368
|
+
/** Issuer(s) to require — YOUR portal host(s), e.g. "manage.example.com", or a list when one backend
|
|
369
|
+
* is fronted by several portal hostnames. Exact match, no wildcards. No default: required unless
|
|
370
|
+
* `validateIss: false`. Omitting both fails closed. */
|
|
371
|
+
expectedIss?: string | string[];
|
|
372
|
+
/** Set false to skip issuer validation (e.g. work across portal domains). Default true, which makes
|
|
373
|
+
* `expectedIss` mandatory. */
|
|
374
|
+
validateIss?: boolean;
|
|
375
|
+
/** ns_t HS256 shared secret. When set, the signature is verified LOCALLY FIRST (forged/tampered
|
|
376
|
+
* tokens are rejected with no roundtrip). When unset, `signature` is 'unverified' and the live
|
|
377
|
+
* `/jwt` check is the signature authority. */
|
|
378
|
+
signingSecret?: string;
|
|
379
|
+
fetchImpl?: typeof fetch;
|
|
380
|
+
nowMs?: number;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Full gate. Order: cheap local format+exp → (live mode) cached live verdict → live GET /jwt.
|
|
385
|
+
* A malformed/expired token returns immediately and never touches the server.
|
|
386
|
+
*/
|
|
387
|
+
export async function verify(token: string, opts: VerifyOptions): Promise<JwtVerdict> {
|
|
388
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
389
|
+
const checkedAt = new Date(nowMs).toISOString();
|
|
390
|
+
const fmt = validateJwtFormat(token, nowMs);
|
|
391
|
+
|
|
392
|
+
const base: JwtVerdict = {
|
|
393
|
+
validFormat: fmt.validFormat,
|
|
394
|
+
unexpired: fmt.unexpired,
|
|
395
|
+
live: 'skipped',
|
|
396
|
+
ok: false,
|
|
397
|
+
...fmt.context,
|
|
398
|
+
...(fmt.expiresAt ? { expiresAt: fmt.expiresAt } : {}),
|
|
399
|
+
...(fmt.expiresInSeconds !== undefined ? { expiresInSeconds: fmt.expiresInSeconds } : {}),
|
|
400
|
+
...(fmt.reason ? { reason: fmt.reason } : {}),
|
|
401
|
+
payload: fmt.payload,
|
|
402
|
+
checkedAt,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Local gate: bad format, expired, or not-yet-valid (nbf) ⇒ reject without a roundtrip.
|
|
406
|
+
if (!fmt.validFormat || !fmt.unexpired || fmt.notYetValid) return base;
|
|
407
|
+
|
|
408
|
+
// Claim assertions (always, no key needed): aud must be "ns"; iss must match unless opted out.
|
|
409
|
+
const claims = assertClaims(fmt.payload ?? {}, { aud: opts.expectedAud, iss: opts.expectedIss, validateIss: opts.validateIss });
|
|
410
|
+
if (!claims.ok) return { ...base, ok: false, reason: claims.reason };
|
|
411
|
+
|
|
412
|
+
// Signature: when a shared secret is configured, verify HS256 LOCALLY FIRST (reject forgeries with
|
|
413
|
+
// no roundtrip). Without a secret we can't verify locally (no public JWKS) → 'unverified', and the
|
|
414
|
+
// live check below is the authority.
|
|
415
|
+
let signature: JwtVerdict['signature'] = 'unverified';
|
|
416
|
+
if (opts.signingSecret) {
|
|
417
|
+
signature = (await verifyHs256Signature(token, opts.signingSecret)) ? 'valid' : 'invalid';
|
|
418
|
+
if (signature === 'invalid') return { ...base, signature, ok: false, reason: 'Signature verification failed' };
|
|
419
|
+
}
|
|
420
|
+
const withSig = { ...base, signature };
|
|
421
|
+
|
|
422
|
+
if ((opts.mode ?? 'live') === 'format') {
|
|
423
|
+
// Local-only mode. `ok` means AUTHENTICATED, so it requires a locally-verified signature
|
|
424
|
+
// (`signingSecret`). ns_t has no public JWKS, so without a secret the signature is 'unverified' ⇒
|
|
425
|
+
// ok:false (structurally + aud/iss valid, but NOT attested). Use mode:'live' — the server-side
|
|
426
|
+
// signature authority — to actually authenticate an ns_t.
|
|
427
|
+
return signature === 'valid'
|
|
428
|
+
? { ...withSig, live: 'skipped', ok: true }
|
|
429
|
+
: { ...withSig, live: 'skipped', ok: false, reason: 'Signature not verified (format mode without signingSecret)' };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Validate the server host BEFORE it reaches the URL: a caller that derives `server` from request
|
|
433
|
+
// input would otherwise let `host@evil` / `host#…` redirect the Bearer token off-origin. Fail
|
|
434
|
+
// closed (uncached error) rather than throw, so a misconfigured server can't crash the caller.
|
|
435
|
+
let safeServer: string;
|
|
436
|
+
try {
|
|
437
|
+
safeServer = assertBareServer(opts.server);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return { ...withSig, live: 'error', ok: false, reason: (err as Error).message };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Live mode — consult cache first (unless force-fresh: writes/sensitive reads always re-check).
|
|
443
|
+
// Key is scoped by server: one cache fronting two NS cores must not cross-serve verdicts.
|
|
444
|
+
const key = await tokenKey(token, safeServer);
|
|
445
|
+
if (opts.cache && !opts.forceFresh) {
|
|
446
|
+
const cached = await opts.cache.get(key);
|
|
447
|
+
if (cached) return { ...cached, fromCache: true, checkedAt };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Cache miss → the one server roundtrip. This is the SIGNATURE/revocation authority, so make it
|
|
451
|
+
// brittle-safe: a timeout (a hung NS core can't tie up the request), NO redirect following
|
|
452
|
+
// (`manual`), and ONLY a literal 200 counts as valid — any 3xx/5xx/other fails closed, uncached.
|
|
453
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 4000);
|
|
456
|
+
let verdict: JwtVerdict;
|
|
457
|
+
try {
|
|
458
|
+
const res = await doFetch(`https://${safeServer}/ns-api/v2/jwt`, {
|
|
459
|
+
method: 'GET',
|
|
460
|
+
headers: { Authorization: `Bearer ${normalizeToken(token)}` },
|
|
461
|
+
redirect: 'manual',
|
|
462
|
+
signal: controller.signal,
|
|
463
|
+
});
|
|
464
|
+
if (res.status === 401 || res.status === 403) {
|
|
465
|
+
verdict = { ...withSig, live: 'invalid', ok: false, statusCode: res.status, reason: `JWT rejected by API (${res.status})` };
|
|
466
|
+
} else if (res.status === 200) {
|
|
467
|
+
verdict = { ...withSig, live: 'valid', ok: true, statusCode: res.status };
|
|
468
|
+
} else {
|
|
469
|
+
// Anything else (3xx redirect, 5xx, opaque, 0) — don't trust it. Fail closed, don't cache.
|
|
470
|
+
verdict = { ...withSig, live: 'error', ok: false, statusCode: res.status, reason: `Unexpected /jwt status ${res.status}` };
|
|
471
|
+
}
|
|
472
|
+
} catch (err) {
|
|
473
|
+
verdict = { ...withSig, live: 'error', ok: false, reason: `JWT check failed: ${(err as Error).message}` };
|
|
474
|
+
} finally {
|
|
475
|
+
clearTimeout(timer);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Cache valid/invalid verdicts (never 'error'). TTL capped by the token's own exp.
|
|
479
|
+
if (opts.cache && (verdict.live === 'valid' || verdict.live === 'invalid')) {
|
|
480
|
+
const cap = verdict.live === 'valid' ? (opts.maxLiveTtlSeconds ?? 60) : (opts.negativeTtlSeconds ?? 30);
|
|
481
|
+
const untilExp = fmt.expiresInSeconds ?? 0;
|
|
482
|
+
const ttl = verdict.live === 'valid' ? Math.max(0, Math.min(cap, untilExp)) : cap;
|
|
483
|
+
// Trim the full decoded claims blob before persisting: nothing downstream reads verdict.payload
|
|
484
|
+
// (toPrincipal uses the typed context fields), and it's the largest PII surface to leave sitting
|
|
485
|
+
// in the per-colo cache. Keep it on the returned verdict (this request only), drop it from storage.
|
|
486
|
+
const { payload: _payload, ...cacheable } = verdict;
|
|
487
|
+
if (ttl > 0) await opts.cache.set(key, cacheable, ttl);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return verdict;
|
|
491
|
+
}
|