human-session-admission.mjs
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db
docs: activate KNOWTATION- board identity and preserve livi…
Human
minor
⚠ breaking
2 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:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db
docs: activate KNOWTATION- board identity and preserve livi…
Human
minor
⚠
2 days ago