human-session-admission.mjs
253 lines 7.8 KB
Raw
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b security: npm audit fix pre-bridge 2026-09-05 Human 3 days ago
1 /**
2 * Hosted human-session claim admission (SESSION-DURABILITY).
3 *
4 * Accepts only type:session tokens with integer iat/exp, exp > iat, total lifetime in
5 * the inclusive 3h–24h band, and (unless checking an already-expired token) unexpired exp.
6 * Distinguishes SESSION_EXPIRED from SESSION_INVALID without leaking token detail.
7 */
8
9 import jwt from 'jsonwebtoken';
10
11 /** Inclusive minimum total lifetime (3 hours) in seconds. */
12 export const HUMAN_SESSION_LIFETIME_MIN_SECONDS = 3 * 60 * 60;
13
14 /** Inclusive maximum total lifetime (24 hours) in seconds. */
15 export const HUMAN_SESSION_LIFETIME_MAX_SECONDS = 24 * 60 * 60;
16
17 /**
18 * True when value is a finite integer (not a float string masquerading as number).
19 * @param {unknown} v
20 * @returns {v is number}
21 */
22 export function isIntegerSeconds(v) {
23 return typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v);
24 }
25
26 /**
27 * Claim-shape check shared by live admission and expired-session classification.
28 * Does not require exp > now.
29 * @param {object} payload
30 * @returns {boolean}
31 */
32 export function humanSessionClaimsShapeOk(payload) {
33 if (!payload || typeof payload !== 'object') return false;
34 if (payload.type !== 'session') return false;
35 if (typeof payload.sub !== 'string' || payload.sub.trim() === '') return false;
36 if (!isIntegerSeconds(payload.iat) || !isIntegerSeconds(payload.exp)) return false;
37 if (payload.exp <= payload.iat) return false;
38 const lifetime = payload.exp - payload.iat;
39 return (
40 lifetime >= HUMAN_SESSION_LIFETIME_MIN_SECONDS &&
41 lifetime <= HUMAN_SESSION_LIFETIME_MAX_SECONDS
42 );
43 }
44
45 /**
46 * Admit a decoded human-session payload for live use.
47 * @param {object} payload
48 * @param {number} [nowSeconds]
49 * @returns {{ ok: true, payload: object } | { ok: false, code: 'SESSION_EXPIRED' | 'SESSION_INVALID' }}
50 */
51 export function admitHumanSessionPayload(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
52 if (!humanSessionClaimsShapeOk(payload)) {
53 return { ok: false, code: 'SESSION_INVALID' };
54 }
55 if (payload.exp <= nowSeconds) {
56 return { ok: false, code: 'SESSION_EXPIRED' };
57 }
58 return { ok: true, payload };
59 }
60
61 /**
62 * Verify JWT signature (primary then previous) with optional ignoreExpiration.
63 * @param {string} token
64 * @param {string} primary
65 * @param {string|null|undefined} previous
66 * @param {{ ignoreExpiration?: boolean }} [opts]
67 * @returns {object|null}
68 */
69 function verifySignature(token, primary, previous, opts = {}) {
70 if (typeof token !== 'string' || token === '') return null;
71 if (typeof primary !== 'string' || primary === '') return null;
72 const verifyOpts = opts.ignoreExpiration ? { ignoreExpiration: true } : {};
73 try {
74 return jwt.verify(token, primary, verifyOpts);
75 } catch (e) {
76 if (!opts.ignoreExpiration && e && e.name === 'TokenExpiredError') {
77 throw e;
78 }
79 }
80 if (typeof previous !== 'string' || previous === '' || previous === primary) return null;
81 try {
82 return jwt.verify(token, previous, verifyOpts);
83 } catch (e) {
84 if (!opts.ignoreExpiration && e && e.name === 'TokenExpiredError') {
85 throw e;
86 }
87 return null;
88 }
89 }
90
91 /**
92 * Private discriminated verification for hosted human-session introspection / establish-refresh.
93 * @param {string} token
94 * @param {string} primary
95 * @param {string|null|undefined} previous
96 * @param {number} [nowSeconds]
97 * @returns {{ ok: true, payload: object } | { ok: false, code: 'SESSION_EXPIRED' | 'SESSION_INVALID' }}
98 */
99 export function verifyHumanSessionAccessToken(
100 token,
101 primary,
102 previous,
103 nowSeconds = Math.floor(Date.now() / 1000),
104 ) {
105 if (typeof token !== 'string' || token === '') {
106 return { ok: false, code: 'SESSION_INVALID' };
107 }
108
109 let payload = null;
110 let sawExpiry = false;
111 try {
112 payload = verifySignature(token, primary, previous, { ignoreExpiration: false });
113 } catch (e) {
114 if (e && e.name === 'TokenExpiredError') {
115 sawExpiry = true;
116 payload = verifySignature(token, primary, previous, { ignoreExpiration: true });
117 } else {
118 payload = null;
119 }
120 }
121
122 if (!payload || typeof payload !== 'object') {
123 return { ok: false, code: 'SESSION_INVALID' };
124 }
125
126 if (!humanSessionClaimsShapeOk(payload)) {
127 return { ok: false, code: 'SESSION_INVALID' };
128 }
129
130 if (sawExpiry || payload.exp <= nowSeconds) {
131 return { ok: false, code: 'SESSION_EXPIRED' };
132 }
133
134 return { ok: true, payload };
135 }
136
137 /**
138 * Hosted-gateway HUB_JWT_EXPIRY grammar: positive integer seconds, or case-insensitive
139 * no-whitespace `^[0-9]+[smhd]$`, converted once to integer seconds in [3h, 24h].
140 *
141 * @param {unknown} raw
142 * @returns {{ ok: true, seconds: number } | { ok: false, error: string }}
143 */
144 export function parseHostedHubJwtExpirySeconds(raw) {
145 const defaultRaw = raw == null || raw === '' ? '24h' : raw;
146 let seconds = null;
147
148 if (typeof defaultRaw === 'number') {
149 if (!Number.isFinite(defaultRaw) || !Number.isInteger(defaultRaw) || defaultRaw <= 0) {
150 return {
151 ok: false,
152 error: 'HUB_JWT_EXPIRY must be a positive integer number of seconds or N[smhd]',
153 };
154 }
155 seconds = defaultRaw;
156 } else if (typeof defaultRaw === 'string') {
157 const s = defaultRaw.trim();
158 if (/^\d+$/.test(s)) {
159 seconds = Number(s);
160 } else if (/^\d+[smhd]$/i.test(s) && !/\s/.test(s)) {
161 const m = s.match(/^(\d+)([smhd])$/i);
162 const n = Number(m[1]);
163 const u = m[2].toLowerCase();
164 if (u === 's') seconds = n;
165 else if (u === 'm') seconds = n * 60;
166 else if (u === 'h') seconds = n * 3600;
167 else seconds = n * 86400;
168 } else {
169 return {
170 ok: false,
171 error:
172 'HUB_JWT_EXPIRY must be a positive integer number of seconds or the form N[smhd] with no whitespace',
173 };
174 }
175 } else {
176 return {
177 ok: false,
178 error: 'HUB_JWT_EXPIRY must be a positive integer number of seconds or N[smhd]',
179 };
180 }
181
182 if (
183 !Number.isInteger(seconds) ||
184 seconds < HUMAN_SESSION_LIFETIME_MIN_SECONDS ||
185 seconds > HUMAN_SESSION_LIFETIME_MAX_SECONDS
186 ) {
187 return {
188 ok: false,
189 error: `HUB_JWT_EXPIRY must resolve to an integer in [${HUMAN_SESSION_LIFETIME_MIN_SECONDS}, ${HUMAN_SESSION_LIFETIME_MAX_SECONDS}] seconds (3h–24h)`,
190 };
191 }
192
193 return { ok: true, seconds };
194 }
195
196 /**
197 * Browser Origin allowlist for establish-refresh (reuses HUB_CORS_ORIGIN / BASE_URL rules).
198 * @param {string|undefined|null} originHeader
199 * @param {string[]} corsOrigins
200 * @param {string} baseUrl
201 * @param {(a: string, b: string) => boolean} [isWwwApexPair]
202 * @returns {boolean}
203 */
204 export function isEstablishRefreshBrowserOriginAllowed(
205 originHeader,
206 corsOrigins,
207 baseUrl,
208 isWwwApexPair,
209 ) {
210 if (originHeader == null || originHeader === '' || originHeader === 'null') return false;
211 let origin;
212 try {
213 const u = new URL(originHeader);
214 if (u.username || u.password || u.search || u.hash) return false;
215 if (u.pathname !== '/' && u.pathname !== '') return false;
216 origin = u.origin;
217 if (String(originHeader) !== origin) return false;
218 } catch {
219 return false;
220 }
221
222 const list = Array.isArray(corsOrigins) ? corsOrigins : [];
223 if (list.length > 0) {
224 if (list.includes(origin)) return true;
225 if (typeof isWwwApexPair === 'function') {
226 for (const o of list) {
227 if (isWwwApexPair(o, origin)) return true;
228 }
229 }
230 return false;
231 }
232
233 try {
234 return origin === new URL(baseUrl).origin;
235 } catch {
236 return false;
237 }
238 }
239
240 /** CLI media type required when Origin is absent. */
241 export const REFRESH_TOKEN_CLI_ACCEPT = 'application/vnd.knowtation.refresh-token+json';
242
243 /**
244 * @param {string|undefined|null} acceptHeader
245 * @returns {boolean}
246 */
247 export function acceptIncludesRefreshTokenCli(acceptHeader) {
248 if (typeof acceptHeader !== 'string' || !acceptHeader) return false;
249 return acceptHeader
250 .split(',')
251 .map((p) => p.split(';')[0].trim().toLowerCase())
252 .includes(REFRESH_TOKEN_CLI_ACCEPT);
253 }
File History 1 commit
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b security: npm audit fix pre-bridge 2026-09-05 Human 3 days ago