hub-session-auth.mjs
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b
security: npm audit fix pre-bridge 2026-09-05
Human
3 days ago
| 1 | /** |
| 2 | * Operator human-lane session auth for hosted Hub REST smokes. |
| 3 | * |
| 4 | * Durable secret: refresh token in ~/.config/knowtation/hub_refresh (or env file path). |
| 5 | * Short JWT: refreshed via POST /api/v1/auth/refresh, cached in ~/.config/knowtation/hub_session. |
| 6 | * |
| 7 | * Not for production cron/Netlify — use kt_agent_ machine lane there (AGENT-INTEGRATION.md). |
| 8 | */ |
| 9 | |
| 10 | import fs from 'node:fs'; |
| 11 | import os from 'node:os'; |
| 12 | import path from 'node:path'; |
| 13 | import jwt from 'jsonwebtoken'; |
| 14 | |
| 15 | export const DEFAULT_REFRESH_FILE = path.join(os.homedir(), '.config', 'knowtation', 'hub_refresh'); |
| 16 | export const DEFAULT_SESSION_FILE = path.join(os.homedir(), '.config', 'knowtation', 'hub_session'); |
| 17 | |
| 18 | const REFRESH_SKEW_MS = 60_000; |
| 19 | const ACCESS_SKEW_S = 120; |
| 20 | |
| 21 | /** |
| 22 | * @param {string} p |
| 23 | * @returns {string} |
| 24 | */ |
| 25 | export function expandHome(p) { |
| 26 | const s = String(p || '').trim(); |
| 27 | if (!s) return s; |
| 28 | return s.startsWith('~') ? path.join(os.homedir(), s.slice(1)) : s; |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * @param {string} fp |
| 33 | * @returns {string} |
| 34 | */ |
| 35 | export function readTrimmedFile(fp) { |
| 36 | return fs.readFileSync(expandHome(fp), 'utf8').trim(); |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * @param {string} fp |
| 41 | * @param {string} contents |
| 42 | */ |
| 43 | export function writeSecretFile(fp, contents) { |
| 44 | const expanded = expandHome(fp); |
| 45 | fs.mkdirSync(path.dirname(expanded), { recursive: true, mode: 0o700 }); |
| 46 | fs.writeFileSync(expanded, contents, { mode: 0o600 }); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * @param {string} token |
| 51 | * @returns {{ sub?: string, type?: string, exp?: number, expired: boolean, expiresInSec: number | null }} |
| 52 | */ |
| 53 | export function decodeAccessClaims(token) { |
| 54 | try { |
| 55 | const payload = jwt.decode(token); |
| 56 | if (!payload || typeof payload !== 'object') { |
| 57 | return { expired: true, expiresInSec: null }; |
| 58 | } |
| 59 | const exp = typeof payload.exp === 'number' ? payload.exp : null; |
| 60 | const now = Math.floor(Date.now() / 1000); |
| 61 | const expiresInSec = exp == null ? null : exp - now; |
| 62 | return { |
| 63 | sub: typeof payload.sub === 'string' ? payload.sub : undefined, |
| 64 | type: typeof payload.type === 'string' ? payload.type : undefined, |
| 65 | exp: exp ?? undefined, |
| 66 | expired: exp != null ? exp <= now + ACCESS_SKEW_S : true, |
| 67 | expiresInSec, |
| 68 | }; |
| 69 | } catch { |
| 70 | return { expired: true, expiresInSec: null }; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * @param {Headers} headers |
| 76 | * @param {string} name |
| 77 | * @returns {string | null} |
| 78 | */ |
| 79 | export function readSetCookieValue(headers, name) { |
| 80 | const lines = |
| 81 | typeof headers.getSetCookie === 'function' |
| 82 | ? headers.getSetCookie() |
| 83 | : [headers.get('set-cookie')].filter(Boolean); |
| 84 | for (const line of lines) { |
| 85 | if (typeof line !== 'string') continue; |
| 86 | const m = line.match(new RegExp(`(?:^|,\\s*)${name}=([^;]+)`)); |
| 87 | if (m) { |
| 88 | try { |
| 89 | return decodeURIComponent(m[1]); |
| 90 | } catch { |
| 91 | return m[1]; |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | return null; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Exchange a valid session access JWT for a durable refresh token (CLI bootstrap). |
| 100 | * |
| 101 | * @param {string} accessToken |
| 102 | * @param {{ apiBase?: string, refreshFile?: string, sessionFile?: string, timeoutMs?: number }} [opts] |
| 103 | */ |
| 104 | export async function establishHostedRefreshFromAccess(accessToken, opts = {}) { |
| 105 | const apiBase = (opts.apiBase || process.env.KNOWTATION_HUB_API || process.env.KNOWTATION_HUB_URL || 'https://api.knowtation.store').replace(/\/$/, ''); |
| 106 | const refreshFile = expandHome(opts.refreshFile || process.env.KNOWTATION_HUB_REFRESH_TOKEN_FILE || DEFAULT_REFRESH_FILE); |
| 107 | const sessionFile = expandHome(opts.sessionFile || process.env.KNOWTATION_HUB_TOKEN_FILE || DEFAULT_SESSION_FILE); |
| 108 | const token = String(accessToken || '').trim(); |
| 109 | if (!token) { |
| 110 | return { ok: false, code: 'ACCESS_MISSING', detail: 'No access token to establish refresh from' }; |
| 111 | } |
| 112 | |
| 113 | const res = await fetch(`${apiBase}/api/v1/auth/establish-refresh`, { |
| 114 | method: 'POST', |
| 115 | headers: { |
| 116 | Accept: 'application/vnd.knowtation.refresh-token+json', |
| 117 | 'Content-Type': 'application/json', |
| 118 | Authorization: `Bearer ${token}`, |
| 119 | }, |
| 120 | signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000), |
| 121 | }); |
| 122 | |
| 123 | const text = await res.text(); |
| 124 | /** @type {{ refresh_token?: string, code?: string, error?: string }} */ |
| 125 | let body = {}; |
| 126 | try { |
| 127 | body = text ? JSON.parse(text) : {}; |
| 128 | } catch { |
| 129 | body = {}; |
| 130 | } |
| 131 | |
| 132 | if (!res.ok) { |
| 133 | return { |
| 134 | ok: false, |
| 135 | code: typeof body.code === 'string' ? body.code : 'ESTABLISH_FAILED', |
| 136 | detail: typeof body.error === 'string' ? body.error : `HTTP ${res.status}`, |
| 137 | httpStatus: res.status, |
| 138 | }; |
| 139 | } |
| 140 | |
| 141 | const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : ''; |
| 142 | if (!refreshToken) { |
| 143 | return { ok: false, code: 'ESTABLISH_NO_REFRESH', detail: 'establish-refresh succeeded but refresh_token missing' }; |
| 144 | } |
| 145 | |
| 146 | writeSecretFile(refreshFile, refreshToken); |
| 147 | writeSecretFile(sessionFile, token); |
| 148 | |
| 149 | return { |
| 150 | ok: true, |
| 151 | refreshToken, |
| 152 | claims: decodeAccessClaims(token), |
| 153 | }; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * @param {{ |
| 158 | * apiBase?: string, |
| 159 | * refreshToken?: string, |
| 160 | * refreshFile?: string, |
| 161 | * sessionFile?: string, |
| 162 | * timeoutMs?: number, |
| 163 | * }} [opts] |
| 164 | * @returns {Promise<{ ok: true, accessToken: string, refreshToken: string, claims: ReturnType<typeof decodeAccessClaims> } | { ok: false, code: string, detail: string, httpStatus?: number }>} |
| 165 | */ |
| 166 | export async function refreshHostedSessionAccessToken(opts = {}) { |
| 167 | const apiBase = (opts.apiBase || process.env.KNOWTATION_HUB_API || process.env.KNOWTATION_HUB_URL || 'https://api.knowtation.store').replace(/\/$/, ''); |
| 168 | const refreshFile = expandHome(opts.refreshFile || process.env.KNOWTATION_HUB_REFRESH_TOKEN_FILE || DEFAULT_REFRESH_FILE); |
| 169 | const sessionFile = expandHome(opts.sessionFile || process.env.KNOWTATION_HUB_TOKEN_FILE || DEFAULT_SESSION_FILE); |
| 170 | |
| 171 | let refreshToken = (opts.refreshToken || process.env.KNOWTATION_HUB_REFRESH_TOKEN || '').trim(); |
| 172 | if (!refreshToken && fs.existsSync(refreshFile)) { |
| 173 | refreshToken = readTrimmedFile(refreshFile); |
| 174 | } |
| 175 | if (!refreshToken) { |
| 176 | return { |
| 177 | ok: false, |
| 178 | code: 'REFRESH_MISSING', |
| 179 | detail: `No refresh token. Bootstrap once with a fresh hub_token from DevTools → Local Storage: |
| 180 | node scripts/hub-session-refresh.mjs --save-access-token '<hub_token>' |
| 181 | (requires POST /api/v1/auth/establish-refresh on hosted gateway — land + deploy if 404)`, |
| 182 | }; |
| 183 | } |
| 184 | |
| 185 | const res = await fetch(`${apiBase}/api/v1/auth/refresh`, { |
| 186 | method: 'POST', |
| 187 | headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, |
| 188 | body: JSON.stringify({ refresh_token: refreshToken }), |
| 189 | signal: AbortSignal.timeout(opts.timeoutMs ?? 20_000), |
| 190 | }); |
| 191 | |
| 192 | const text = await res.text(); |
| 193 | /** @type {{ access_token?: string, code?: string, error?: string }} */ |
| 194 | let body = {}; |
| 195 | try { |
| 196 | body = text ? JSON.parse(text) : {}; |
| 197 | } catch { |
| 198 | body = {}; |
| 199 | } |
| 200 | |
| 201 | if (!res.ok) { |
| 202 | return { |
| 203 | ok: false, |
| 204 | code: typeof body.code === 'string' ? body.code : 'REFRESH_FAILED', |
| 205 | detail: typeof body.error === 'string' ? body.error : `HTTP ${res.status}`, |
| 206 | httpStatus: res.status, |
| 207 | }; |
| 208 | } |
| 209 | |
| 210 | const accessToken = typeof body.access_token === 'string' ? body.access_token : ''; |
| 211 | if (!accessToken) { |
| 212 | return { ok: false, code: 'REFRESH_NO_ACCESS', detail: 'Refresh succeeded but access_token missing' }; |
| 213 | } |
| 214 | |
| 215 | const rotated = readSetCookieValue(res.headers, 'ktn_refresh') || refreshToken; |
| 216 | writeSecretFile(refreshFile, rotated); |
| 217 | writeSecretFile(sessionFile, accessToken); |
| 218 | |
| 219 | return { |
| 220 | ok: true, |
| 221 | accessToken, |
| 222 | refreshToken: rotated, |
| 223 | claims: decodeAccessClaims(accessToken), |
| 224 | }; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Return a valid hosted session access JWT, refreshing when expired. |
| 229 | * |
| 230 | * Precedence: non-expired KNOWTATION_HUB_TOKEN env → session file → refresh. |
| 231 | * |
| 232 | * @param {{ apiBase?: string, forceRefresh?: boolean }} [opts] |
| 233 | * @returns {Promise<{ ok: true, accessToken: string, source: string, refreshed: boolean } | { ok: false, code: string, detail: string }>} |
| 234 | */ |
| 235 | export async function ensureHostedSessionAccessToken(opts = {}) { |
| 236 | const sessionFile = expandHome(process.env.KNOWTATION_HUB_TOKEN_FILE || DEFAULT_SESSION_FILE); |
| 237 | const envToken = (process.env.KNOWTATION_HUB_TOKEN || process.env.HUB_JWT || '').trim(); |
| 238 | |
| 239 | if (!opts.forceRefresh && envToken) { |
| 240 | const claims = decodeAccessClaims(envToken); |
| 241 | if (!claims.expired) { |
| 242 | return { ok: true, accessToken: envToken, source: 'env', refreshed: false }; |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | if (!opts.forceRefresh && fs.existsSync(sessionFile)) { |
| 247 | const fileToken = readTrimmedFile(sessionFile); |
| 248 | const claims = decodeAccessClaims(fileToken); |
| 249 | if (!claims.expired) { |
| 250 | return { ok: true, accessToken: fileToken, source: 'session_file', refreshed: false }; |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | const refreshed = await refreshHostedSessionAccessToken({ apiBase: opts.apiBase }); |
| 255 | if (!refreshed.ok) return refreshed; |
| 256 | return { |
| 257 | ok: true, |
| 258 | accessToken: refreshed.accessToken, |
| 259 | source: 'refresh', |
| 260 | refreshed: true, |
| 261 | }; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Mint a legacy_session-shaped JWT for hosted probes when operator SESSION_SECRET is in env. |
| 266 | * Never persist; never log the token. |
| 267 | * |
| 268 | * @param {{ sub?: string }} [opts] |
| 269 | * @returns {{ ok: true, accessToken: string } | { ok: false, code: string, detail: string }} |
| 270 | */ |
| 271 | export function mintLegacySessionAccessToken(opts = {}) { |
| 272 | const secret = (process.env.KNOWTATION_SESSION_SECRET || process.env.SESSION_SECRET || '').trim(); |
| 273 | if (!secret) { |
| 274 | return { |
| 275 | ok: false, |
| 276 | code: 'SESSION_SECRET_MISSING', |
| 277 | detail: 'Set KNOWTATION_SESSION_SECRET (production SESSION_SECRET) locally to probe legacy_session class', |
| 278 | }; |
| 279 | } |
| 280 | const sub = opts.sub || 'operator-deploy-proof-legacy'; |
| 281 | const accessToken = jwt.sign({ sub, role: 'admin' }, secret, { expiresIn: '15m' }); |
| 282 | return { ok: true, accessToken }; |
| 283 | } |
File History
1 commit
sha256:4215cecbbabf5591b1ff69053cc938b4b6c800f0d487be40618c1d4254d8b67b
security: npm audit fix pre-bridge 2026-09-05
Human
3 days ago