hub-session-auth.mjs
283 lines 9.5 KB
Raw
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