@apitap/core 1.0.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.
Files changed (236) hide show
  1. package/LICENSE +60 -0
  2. package/README.md +362 -0
  3. package/SKILL.md +270 -0
  4. package/dist/auth/crypto.d.ts +31 -0
  5. package/dist/auth/crypto.js +66 -0
  6. package/dist/auth/crypto.js.map +1 -0
  7. package/dist/auth/handoff.d.ts +29 -0
  8. package/dist/auth/handoff.js +180 -0
  9. package/dist/auth/handoff.js.map +1 -0
  10. package/dist/auth/manager.d.ts +46 -0
  11. package/dist/auth/manager.js +127 -0
  12. package/dist/auth/manager.js.map +1 -0
  13. package/dist/auth/oauth-refresh.d.ts +16 -0
  14. package/dist/auth/oauth-refresh.js +91 -0
  15. package/dist/auth/oauth-refresh.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +43 -0
  17. package/dist/auth/refresh.js +217 -0
  18. package/dist/auth/refresh.js.map +1 -0
  19. package/dist/capture/anti-bot.d.ts +15 -0
  20. package/dist/capture/anti-bot.js +43 -0
  21. package/dist/capture/anti-bot.js.map +1 -0
  22. package/dist/capture/blocklist.d.ts +6 -0
  23. package/dist/capture/blocklist.js +70 -0
  24. package/dist/capture/blocklist.js.map +1 -0
  25. package/dist/capture/body-diff.d.ts +8 -0
  26. package/dist/capture/body-diff.js +102 -0
  27. package/dist/capture/body-diff.js.map +1 -0
  28. package/dist/capture/body-variables.d.ts +13 -0
  29. package/dist/capture/body-variables.js +142 -0
  30. package/dist/capture/body-variables.js.map +1 -0
  31. package/dist/capture/domain.d.ts +8 -0
  32. package/dist/capture/domain.js +34 -0
  33. package/dist/capture/domain.js.map +1 -0
  34. package/dist/capture/entropy.d.ts +33 -0
  35. package/dist/capture/entropy.js +100 -0
  36. package/dist/capture/entropy.js.map +1 -0
  37. package/dist/capture/filter.d.ts +11 -0
  38. package/dist/capture/filter.js +49 -0
  39. package/dist/capture/filter.js.map +1 -0
  40. package/dist/capture/graphql.d.ts +21 -0
  41. package/dist/capture/graphql.js +99 -0
  42. package/dist/capture/graphql.js.map +1 -0
  43. package/dist/capture/idle.d.ts +23 -0
  44. package/dist/capture/idle.js +44 -0
  45. package/dist/capture/idle.js.map +1 -0
  46. package/dist/capture/monitor.d.ts +26 -0
  47. package/dist/capture/monitor.js +183 -0
  48. package/dist/capture/monitor.js.map +1 -0
  49. package/dist/capture/oauth-detector.d.ts +18 -0
  50. package/dist/capture/oauth-detector.js +96 -0
  51. package/dist/capture/oauth-detector.js.map +1 -0
  52. package/dist/capture/pagination.d.ts +9 -0
  53. package/dist/capture/pagination.js +40 -0
  54. package/dist/capture/pagination.js.map +1 -0
  55. package/dist/capture/parameterize.d.ts +17 -0
  56. package/dist/capture/parameterize.js +63 -0
  57. package/dist/capture/parameterize.js.map +1 -0
  58. package/dist/capture/scrubber.d.ts +5 -0
  59. package/dist/capture/scrubber.js +38 -0
  60. package/dist/capture/scrubber.js.map +1 -0
  61. package/dist/capture/session.d.ts +46 -0
  62. package/dist/capture/session.js +445 -0
  63. package/dist/capture/session.js.map +1 -0
  64. package/dist/capture/token-detector.d.ts +16 -0
  65. package/dist/capture/token-detector.js +62 -0
  66. package/dist/capture/token-detector.js.map +1 -0
  67. package/dist/capture/verifier.d.ts +17 -0
  68. package/dist/capture/verifier.js +147 -0
  69. package/dist/capture/verifier.js.map +1 -0
  70. package/dist/cli.d.ts +2 -0
  71. package/dist/cli.js +930 -0
  72. package/dist/cli.js.map +1 -0
  73. package/dist/discovery/auth.d.ts +17 -0
  74. package/dist/discovery/auth.js +81 -0
  75. package/dist/discovery/auth.js.map +1 -0
  76. package/dist/discovery/fetch.d.ts +17 -0
  77. package/dist/discovery/fetch.js +59 -0
  78. package/dist/discovery/fetch.js.map +1 -0
  79. package/dist/discovery/frameworks.d.ts +11 -0
  80. package/dist/discovery/frameworks.js +249 -0
  81. package/dist/discovery/frameworks.js.map +1 -0
  82. package/dist/discovery/index.d.ts +21 -0
  83. package/dist/discovery/index.js +219 -0
  84. package/dist/discovery/index.js.map +1 -0
  85. package/dist/discovery/openapi.d.ts +13 -0
  86. package/dist/discovery/openapi.js +175 -0
  87. package/dist/discovery/openapi.js.map +1 -0
  88. package/dist/discovery/probes.d.ts +9 -0
  89. package/dist/discovery/probes.js +70 -0
  90. package/dist/discovery/probes.js.map +1 -0
  91. package/dist/index.d.ts +25 -0
  92. package/dist/index.js +25 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/inspect/report.d.ts +52 -0
  95. package/dist/inspect/report.js +191 -0
  96. package/dist/inspect/report.js.map +1 -0
  97. package/dist/mcp.d.ts +8 -0
  98. package/dist/mcp.js +526 -0
  99. package/dist/mcp.js.map +1 -0
  100. package/dist/orchestration/browse.d.ts +38 -0
  101. package/dist/orchestration/browse.js +198 -0
  102. package/dist/orchestration/browse.js.map +1 -0
  103. package/dist/orchestration/cache.d.ts +15 -0
  104. package/dist/orchestration/cache.js +24 -0
  105. package/dist/orchestration/cache.js.map +1 -0
  106. package/dist/plugin.d.ts +17 -0
  107. package/dist/plugin.js +158 -0
  108. package/dist/plugin.js.map +1 -0
  109. package/dist/read/decoders/deepwiki.d.ts +2 -0
  110. package/dist/read/decoders/deepwiki.js +148 -0
  111. package/dist/read/decoders/deepwiki.js.map +1 -0
  112. package/dist/read/decoders/grokipedia.d.ts +2 -0
  113. package/dist/read/decoders/grokipedia.js +210 -0
  114. package/dist/read/decoders/grokipedia.js.map +1 -0
  115. package/dist/read/decoders/hackernews.d.ts +2 -0
  116. package/dist/read/decoders/hackernews.js +168 -0
  117. package/dist/read/decoders/hackernews.js.map +1 -0
  118. package/dist/read/decoders/index.d.ts +2 -0
  119. package/dist/read/decoders/index.js +12 -0
  120. package/dist/read/decoders/index.js.map +1 -0
  121. package/dist/read/decoders/reddit.d.ts +2 -0
  122. package/dist/read/decoders/reddit.js +142 -0
  123. package/dist/read/decoders/reddit.js.map +1 -0
  124. package/dist/read/decoders/twitter.d.ts +12 -0
  125. package/dist/read/decoders/twitter.js +187 -0
  126. package/dist/read/decoders/twitter.js.map +1 -0
  127. package/dist/read/decoders/wikipedia.d.ts +2 -0
  128. package/dist/read/decoders/wikipedia.js +66 -0
  129. package/dist/read/decoders/wikipedia.js.map +1 -0
  130. package/dist/read/decoders/youtube.d.ts +2 -0
  131. package/dist/read/decoders/youtube.js +69 -0
  132. package/dist/read/decoders/youtube.js.map +1 -0
  133. package/dist/read/extract.d.ts +25 -0
  134. package/dist/read/extract.js +320 -0
  135. package/dist/read/extract.js.map +1 -0
  136. package/dist/read/index.d.ts +14 -0
  137. package/dist/read/index.js +66 -0
  138. package/dist/read/index.js.map +1 -0
  139. package/dist/read/peek.d.ts +9 -0
  140. package/dist/read/peek.js +137 -0
  141. package/dist/read/peek.js.map +1 -0
  142. package/dist/read/types.d.ts +44 -0
  143. package/dist/read/types.js +3 -0
  144. package/dist/read/types.js.map +1 -0
  145. package/dist/replay/engine.d.ts +53 -0
  146. package/dist/replay/engine.js +441 -0
  147. package/dist/replay/engine.js.map +1 -0
  148. package/dist/replay/truncate.d.ts +16 -0
  149. package/dist/replay/truncate.js +92 -0
  150. package/dist/replay/truncate.js.map +1 -0
  151. package/dist/serve.d.ts +31 -0
  152. package/dist/serve.js +149 -0
  153. package/dist/serve.js.map +1 -0
  154. package/dist/skill/generator.d.ts +44 -0
  155. package/dist/skill/generator.js +419 -0
  156. package/dist/skill/generator.js.map +1 -0
  157. package/dist/skill/importer.d.ts +26 -0
  158. package/dist/skill/importer.js +80 -0
  159. package/dist/skill/importer.js.map +1 -0
  160. package/dist/skill/search.d.ts +19 -0
  161. package/dist/skill/search.js +51 -0
  162. package/dist/skill/search.js.map +1 -0
  163. package/dist/skill/signing.d.ts +16 -0
  164. package/dist/skill/signing.js +34 -0
  165. package/dist/skill/signing.js.map +1 -0
  166. package/dist/skill/ssrf.d.ts +27 -0
  167. package/dist/skill/ssrf.js +210 -0
  168. package/dist/skill/ssrf.js.map +1 -0
  169. package/dist/skill/store.d.ts +7 -0
  170. package/dist/skill/store.js +93 -0
  171. package/dist/skill/store.js.map +1 -0
  172. package/dist/stats/report.d.ts +26 -0
  173. package/dist/stats/report.js +157 -0
  174. package/dist/stats/report.js.map +1 -0
  175. package/dist/types.d.ts +214 -0
  176. package/dist/types.js +3 -0
  177. package/dist/types.js.map +1 -0
  178. package/package.json +58 -0
  179. package/src/auth/crypto.ts +92 -0
  180. package/src/auth/handoff.ts +229 -0
  181. package/src/auth/manager.ts +140 -0
  182. package/src/auth/oauth-refresh.ts +120 -0
  183. package/src/auth/refresh.ts +300 -0
  184. package/src/capture/anti-bot.ts +63 -0
  185. package/src/capture/blocklist.ts +75 -0
  186. package/src/capture/body-diff.ts +109 -0
  187. package/src/capture/body-variables.ts +156 -0
  188. package/src/capture/domain.ts +34 -0
  189. package/src/capture/entropy.ts +121 -0
  190. package/src/capture/filter.ts +56 -0
  191. package/src/capture/graphql.ts +124 -0
  192. package/src/capture/idle.ts +45 -0
  193. package/src/capture/monitor.ts +224 -0
  194. package/src/capture/oauth-detector.ts +106 -0
  195. package/src/capture/pagination.ts +49 -0
  196. package/src/capture/parameterize.ts +68 -0
  197. package/src/capture/scrubber.ts +49 -0
  198. package/src/capture/session.ts +502 -0
  199. package/src/capture/token-detector.ts +76 -0
  200. package/src/capture/verifier.ts +171 -0
  201. package/src/cli.ts +1031 -0
  202. package/src/discovery/auth.ts +99 -0
  203. package/src/discovery/fetch.ts +85 -0
  204. package/src/discovery/frameworks.ts +231 -0
  205. package/src/discovery/index.ts +256 -0
  206. package/src/discovery/openapi.ts +230 -0
  207. package/src/discovery/probes.ts +76 -0
  208. package/src/index.ts +26 -0
  209. package/src/inspect/report.ts +247 -0
  210. package/src/mcp.ts +618 -0
  211. package/src/orchestration/browse.ts +250 -0
  212. package/src/orchestration/cache.ts +37 -0
  213. package/src/plugin.ts +188 -0
  214. package/src/read/decoders/deepwiki.ts +180 -0
  215. package/src/read/decoders/grokipedia.ts +246 -0
  216. package/src/read/decoders/hackernews.ts +198 -0
  217. package/src/read/decoders/index.ts +15 -0
  218. package/src/read/decoders/reddit.ts +158 -0
  219. package/src/read/decoders/twitter.ts +211 -0
  220. package/src/read/decoders/wikipedia.ts +75 -0
  221. package/src/read/decoders/youtube.ts +75 -0
  222. package/src/read/extract.ts +396 -0
  223. package/src/read/index.ts +78 -0
  224. package/src/read/peek.ts +175 -0
  225. package/src/read/types.ts +37 -0
  226. package/src/replay/engine.ts +559 -0
  227. package/src/replay/truncate.ts +116 -0
  228. package/src/serve.ts +189 -0
  229. package/src/skill/generator.ts +473 -0
  230. package/src/skill/importer.ts +107 -0
  231. package/src/skill/search.ts +76 -0
  232. package/src/skill/signing.ts +36 -0
  233. package/src/skill/ssrf.ts +238 -0
  234. package/src/skill/store.ts +107 -0
  235. package/src/stats/report.ts +208 -0
  236. package/src/types.ts +233 -0
@@ -0,0 +1,229 @@
1
+ // src/auth/handoff.ts
2
+ import type { AuthManager } from './manager.js';
3
+ import type { StoredSession, StoredAuth } from '../types.js';
4
+
5
+ export interface HandoffOptions {
6
+ domain: string;
7
+ loginUrl?: string; // URL to navigate to (defaults to https://<domain>)
8
+ timeout?: number; // ms, default 300000 (5 minutes)
9
+ }
10
+
11
+ export interface HandoffResult {
12
+ success: boolean;
13
+ cookieCount: number;
14
+ authDetected?: 'bearer' | 'cookie' | 'api-key';
15
+ error?: string;
16
+ }
17
+
18
+ // Session-like cookie name patterns
19
+ const SESSION_COOKIE_PATTERNS = [
20
+ /sess/i, /auth/i, /token/i, /jwt/i, /login/i,
21
+ /sid$/i, /^_session/i, /^connect\.sid$/i,
22
+ ];
23
+
24
+ // Tracking/analytics cookie patterns (exclude from session detection)
25
+ const TRACKING_COOKIE_PATTERNS = [
26
+ /^_ga/i, /^_gid/i, /^_fb/i, /^_gcl/i, /^__utm/i,
27
+ ];
28
+
29
+ /**
30
+ * Detect whether a response indicates successful login.
31
+ * Checks for session-like Set-Cookie headers on 2xx responses.
32
+ */
33
+ export function detectLoginSuccess(
34
+ headers: Map<string, string>,
35
+ status: number,
36
+ ): boolean {
37
+ if (status < 200 || status >= 300) return false;
38
+
39
+ // Check for auth header
40
+ const authHeader = headers.get('authorization');
41
+ if (authHeader && (authHeader.startsWith('Bearer ') || authHeader.startsWith('Basic '))) {
42
+ return true;
43
+ }
44
+
45
+ // Check for session-like cookies
46
+ const setCookie = headers.get('set-cookie');
47
+ if (!setCookie) return false;
48
+
49
+ // Parse cookie name from Set-Cookie header
50
+ const cookieName = setCookie.split('=')[0].trim();
51
+
52
+ // Exclude tracking cookies
53
+ if (TRACKING_COOKIE_PATTERNS.some(p => p.test(cookieName))) return false;
54
+
55
+ // Match session-like cookies
56
+ return SESSION_COOKIE_PATTERNS.some(p => p.test(cookieName));
57
+ }
58
+
59
+ // Mutex to prevent concurrent handoffs for the same domain
60
+ const handoffLocks = new Map<string, Promise<HandoffResult>>();
61
+
62
+ /**
63
+ * Open a visible browser for human authentication.
64
+ *
65
+ * Flow:
66
+ * 1. Launch visible Chromium browser
67
+ * 2. Navigate to login URL
68
+ * 3. Wait for human to log in (watches for session cookies / auth headers)
69
+ * 4. Capture all cookies + detected auth
70
+ * 5. Store encrypted via AuthManager
71
+ * 6. Close browser, return result
72
+ */
73
+ export async function requestAuth(
74
+ authManager: AuthManager,
75
+ options: HandoffOptions,
76
+ ): Promise<HandoffResult> {
77
+ const { domain } = options;
78
+
79
+ // Mutex: prevent concurrent handoffs for same domain
80
+ const existing = handoffLocks.get(domain);
81
+ if (existing) return existing;
82
+
83
+ const promise = doHandoff(authManager, options);
84
+ handoffLocks.set(domain, promise);
85
+
86
+ try {
87
+ return await promise;
88
+ } finally {
89
+ handoffLocks.delete(domain);
90
+ }
91
+ }
92
+
93
+ async function doHandoff(
94
+ authManager: AuthManager,
95
+ options: HandoffOptions,
96
+ ): Promise<HandoffResult> {
97
+ const { domain } = options;
98
+ const loginUrl = options.loginUrl || `https://${domain}`;
99
+ const timeout = options.timeout ?? 300_000; // 5 minutes
100
+
101
+ const { chromium } = await import('playwright');
102
+
103
+ const browser = await chromium.launch({ headless: false });
104
+
105
+ try {
106
+ const context = await browser.newContext();
107
+
108
+ // Restore existing session cookies if available (warm start)
109
+ const cachedSession = await authManager.retrieveSession(domain);
110
+ if (cachedSession?.cookies?.length) {
111
+ await context.addCookies(cachedSession.cookies);
112
+ }
113
+
114
+ const page = await context.newPage();
115
+ let authDetected: 'bearer' | 'cookie' | 'api-key' | undefined;
116
+ let detectedAuth: StoredAuth | undefined;
117
+
118
+ // Watch network responses for auth signals
119
+ page.on('response', (response) => {
120
+ const headers = new Map<string, string>();
121
+ for (const [key, value] of Object.entries(response.headers())) {
122
+ headers.set(key, value);
123
+ }
124
+
125
+ // Detect auth from request headers
126
+ const authHeader = response.request().headers()['authorization'];
127
+ if (authHeader) {
128
+ if (authHeader.startsWith('Bearer ')) {
129
+ authDetected = 'bearer';
130
+ detectedAuth = {
131
+ type: 'bearer',
132
+ header: 'authorization',
133
+ value: authHeader,
134
+ };
135
+ } else if (authHeader.toLowerCase().startsWith('apikey ') || authHeader.toLowerCase().startsWith('api-key ')) {
136
+ authDetected = 'api-key';
137
+ detectedAuth = {
138
+ type: 'api-key',
139
+ header: 'authorization',
140
+ value: authHeader,
141
+ };
142
+ }
143
+ }
144
+ });
145
+
146
+ // Navigate to login page
147
+ await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
148
+
149
+ // Poll for login success: check cookies periodically
150
+ const startTime = Date.now();
151
+ let loginDetected = false;
152
+
153
+ while (Date.now() - startTime < timeout) {
154
+ await page.waitForTimeout(2000);
155
+
156
+ // Check if we've detected session cookies
157
+ const cookies = await context.cookies();
158
+ const hasSessionCookie = cookies.some(c =>
159
+ SESSION_COOKIE_PATTERNS.some(p => p.test(c.name)) &&
160
+ !TRACKING_COOKIE_PATTERNS.some(p => p.test(c.name))
161
+ );
162
+
163
+ if (hasSessionCookie || authDetected) {
164
+ // Wait a bit more for any final redirects/requests
165
+ await page.waitForTimeout(2000);
166
+ loginDetected = true;
167
+ break;
168
+ }
169
+
170
+ // Check if browser was closed by user
171
+ if (page.isClosed()) break;
172
+ }
173
+
174
+ // Capture final cookies
175
+ const cookies = await context.cookies();
176
+
177
+ if (cookies.length === 0 && !authDetected) {
178
+ return {
179
+ success: false,
180
+ cookieCount: 0,
181
+ error: loginDetected ? undefined : 'Timeout: no login detected within the allowed time',
182
+ };
183
+ }
184
+
185
+ // Store session cookies
186
+ const session: StoredSession = {
187
+ cookies,
188
+ savedAt: new Date().toISOString(),
189
+ maxAgeMs: 24 * 60 * 60 * 1000, // 24 hours
190
+ };
191
+ await authManager.storeSession(domain, session);
192
+
193
+ // Store detected auth header if found
194
+ if (detectedAuth) {
195
+ await authManager.store(domain, detectedAuth);
196
+ } else if (cookies.length > 0) {
197
+ // Store as cookie auth
198
+ const sessionCookies = cookies
199
+ .filter(c => SESSION_COOKIE_PATTERNS.some(p => p.test(c.name)))
200
+ .filter(c => !TRACKING_COOKIE_PATTERNS.some(p => p.test(c.name)));
201
+
202
+ if (sessionCookies.length > 0) {
203
+ authDetected = 'cookie';
204
+ const cookieHeader = sessionCookies
205
+ .map(c => `${c.name}=${c.value}`)
206
+ .join('; ');
207
+ await authManager.store(domain, {
208
+ type: 'cookie',
209
+ header: 'cookie',
210
+ value: cookieHeader,
211
+ });
212
+ }
213
+ }
214
+
215
+ return {
216
+ success: true,
217
+ cookieCount: cookies.length,
218
+ authDetected,
219
+ };
220
+ } catch (error) {
221
+ return {
222
+ success: false,
223
+ cookieCount: 0,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ };
226
+ } finally {
227
+ await browser.close();
228
+ }
229
+ }
@@ -0,0 +1,140 @@
1
+ // src/auth/manager.ts
2
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { encrypt, decrypt, deriveKey, type EncryptedData } from './crypto.js';
5
+ import type { StoredAuth, StoredToken, StoredSession } from '../types.js';
6
+
7
+ const AUTH_FILENAME = 'auth.enc';
8
+
9
+ /**
10
+ * Manages encrypted auth credential storage.
11
+ * All credentials stored in a single encrypted file keyed by domain.
12
+ */
13
+ export class AuthManager {
14
+ private key: Buffer;
15
+ private authPath: string;
16
+
17
+ constructor(baseDir: string, machineId: string) {
18
+ this.key = deriveKey(machineId);
19
+ this.authPath = join(baseDir, AUTH_FILENAME);
20
+ }
21
+
22
+ /** Store auth credentials for a domain (overwrites existing). */
23
+ async store(domain: string, auth: StoredAuth): Promise<void> {
24
+ const allAuth = await this.loadAll();
25
+ allAuth[domain] = auth;
26
+ await this.saveAll(allAuth);
27
+ }
28
+
29
+ /** Retrieve auth credentials for a domain. Returns null if not found or decryption fails. */
30
+ async retrieve(domain: string): Promise<StoredAuth | null> {
31
+ const allAuth = await this.loadAll();
32
+ return allAuth[domain] ?? null;
33
+ }
34
+
35
+ /** Check if auth exists for a domain without loading the value. */
36
+ async has(domain: string): Promise<boolean> {
37
+ const allAuth = await this.loadAll();
38
+ return domain in allAuth;
39
+ }
40
+
41
+ /** Store refreshable tokens for a domain (merges with existing auth). */
42
+ async storeTokens(domain: string, tokens: Record<string, StoredToken>): Promise<void> {
43
+ const all = await this.loadAll();
44
+ const existing = all[domain] || { type: 'custom' as const, header: '', value: '' };
45
+ all[domain] = { ...existing, tokens };
46
+ await this.saveAll(all);
47
+ }
48
+
49
+ /** Retrieve refreshable tokens for a domain. */
50
+ async retrieveTokens(domain: string): Promise<Record<string, StoredToken> | null> {
51
+ const all = await this.loadAll();
52
+ return all[domain]?.tokens ?? null;
53
+ }
54
+
55
+ /** Store browser session (cookies) for a domain (merges with existing auth). */
56
+ async storeSession(domain: string, session: StoredSession): Promise<void> {
57
+ const all = await this.loadAll();
58
+ const existing = all[domain] || { type: 'custom' as const, header: '', value: '' };
59
+ all[domain] = { ...existing, session };
60
+ await this.saveAll(all);
61
+ }
62
+
63
+ /** Retrieve browser session for a domain. */
64
+ async retrieveSession(domain: string): Promise<StoredSession | null> {
65
+ const all = await this.loadAll();
66
+ return all[domain]?.session ?? null;
67
+ }
68
+
69
+ /** Store OAuth credentials for a domain (merges with existing auth). */
70
+ async storeOAuthCredentials(domain: string, creds: { refreshToken?: string; clientSecret?: string }): Promise<void> {
71
+ const all = await this.loadAll();
72
+ const existing = all[domain] || { type: 'custom' as const, header: '', value: '' };
73
+ if (creds.refreshToken !== undefined) existing.refreshToken = creds.refreshToken;
74
+ if (creds.clientSecret !== undefined) existing.clientSecret = creds.clientSecret;
75
+ all[domain] = existing;
76
+ await this.saveAll(all);
77
+ }
78
+
79
+ /** Retrieve OAuth credentials for a domain. */
80
+ async retrieveOAuthCredentials(domain: string): Promise<{ refreshToken?: string; clientSecret?: string } | null> {
81
+ const all = await this.loadAll();
82
+ const auth = all[domain];
83
+ if (!auth) return null;
84
+ if (!auth.refreshToken && !auth.clientSecret) return null;
85
+ return { refreshToken: auth.refreshToken, clientSecret: auth.clientSecret };
86
+ }
87
+
88
+ /** List all domains with stored auth. */
89
+ async listDomains(): Promise<string[]> {
90
+ const all = await this.loadAll();
91
+ return Object.keys(all);
92
+ }
93
+
94
+ /** Clear all auth for a domain. */
95
+ async clear(domain: string): Promise<void> {
96
+ const all = await this.loadAll();
97
+ delete all[domain];
98
+ await this.saveAll(all);
99
+ }
100
+
101
+ private async loadAll(): Promise<Record<string, StoredAuth>> {
102
+ try {
103
+ const content = await readFile(this.authPath, 'utf-8');
104
+ const encrypted: EncryptedData = JSON.parse(content);
105
+ const plaintext = decrypt(encrypted, this.key);
106
+ return JSON.parse(plaintext);
107
+ } catch {
108
+ return {};
109
+ }
110
+ }
111
+
112
+ private async saveAll(data: Record<string, StoredAuth>): Promise<void> {
113
+ const dir = join(this.authPath, '..');
114
+ await mkdir(dir, { recursive: true });
115
+
116
+ const plaintext = JSON.stringify(data);
117
+ const encrypted = encrypt(plaintext, this.key);
118
+
119
+ await writeFile(this.authPath, JSON.stringify(encrypted, null, 2) + '\n', { mode: 0o600 });
120
+ // Ensure permissions even if file existed with different perms
121
+ await chmod(this.authPath, 0o600);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Get the machine ID for key derivation.
127
+ * Linux: /etc/machine-id
128
+ * Fallback: hostname + homedir (less secure but portable)
129
+ */
130
+ export async function getMachineId(): Promise<string> {
131
+ try {
132
+ const id = await readFile('/etc/machine-id', 'utf-8');
133
+ return id.trim();
134
+ } catch {
135
+ // Fallback for non-Linux systems
136
+ const { hostname } = await import('node:os');
137
+ const { homedir } = await import('node:os');
138
+ return `${hostname()}-${homedir()}`;
139
+ }
140
+ }
@@ -0,0 +1,120 @@
1
+ // src/auth/oauth-refresh.ts
2
+ import type { OAuthConfig } from '../types.js';
3
+ import type { AuthManager } from './manager.js';
4
+ import { resolveAndValidateUrl } from '../skill/ssrf.js';
5
+
6
+ export interface OAuthRefreshResult {
7
+ success: boolean;
8
+ accessToken?: string;
9
+ tokenRotated?: boolean;
10
+ error?: string;
11
+ }
12
+
13
+ /**
14
+ * Refresh an OAuth2 access token via the token endpoint using stdlib fetch().
15
+ * Supports refresh_token and client_credentials grant types.
16
+ * Handles refresh token rotation (new refresh_token in response).
17
+ */
18
+ export async function refreshOAuth(
19
+ domain: string,
20
+ oauthConfig: OAuthConfig,
21
+ authManager: AuthManager,
22
+ options?: { _skipSsrfCheck?: boolean },
23
+ ): Promise<OAuthRefreshResult> {
24
+ const oauthCreds = await authManager.retrieveOAuthCredentials(domain);
25
+
26
+ // Build request body based on grant type
27
+ const body = new URLSearchParams();
28
+ body.append('grant_type', oauthConfig.grantType);
29
+ body.append('client_id', oauthConfig.clientId);
30
+
31
+ if (oauthConfig.scope) {
32
+ body.append('scope', oauthConfig.scope);
33
+ }
34
+
35
+ if (oauthConfig.grantType === 'refresh_token') {
36
+ if (!oauthCreds?.refreshToken) {
37
+ return { success: false, error: 'No refresh token available' };
38
+ }
39
+ body.append('refresh_token', oauthCreds.refreshToken);
40
+ }
41
+
42
+ if (oauthCreds?.clientSecret) {
43
+ body.append('client_secret', oauthCreds.clientSecret);
44
+ }
45
+
46
+ // SSRF check on token endpoint
47
+ if (!options?._skipSsrfCheck) {
48
+ const ssrfCheck = await resolveAndValidateUrl(oauthConfig.tokenEndpoint);
49
+ if (!ssrfCheck.safe) {
50
+ return { success: false, error: `Token endpoint blocked: ${ssrfCheck.reason}` };
51
+ }
52
+ }
53
+
54
+ // Domain match: token endpoint must match skill domain or be a known OAuth provider
55
+ const KNOWN_OAUTH_HOSTS = [
56
+ 'oauth2.googleapis.com', 'accounts.google.com',
57
+ 'login.microsoftonline.com', 'github.com',
58
+ 'oauth.reddit.com', 'api.twitter.com',
59
+ ];
60
+ const tokenHost = new URL(oauthConfig.tokenEndpoint).hostname;
61
+ if (tokenHost !== domain && !tokenHost.endsWith('.' + domain) && !KNOWN_OAUTH_HOSTS.includes(tokenHost)) {
62
+ return { success: false, error: `Token endpoint domain mismatch: ${tokenHost} vs ${domain}` };
63
+ }
64
+
65
+ try {
66
+ const response = await fetch(oauthConfig.tokenEndpoint, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
69
+ body: body.toString(),
70
+ signal: AbortSignal.timeout(15_000), // 15s timeout for token refresh
71
+ });
72
+
73
+ if (!response.ok) {
74
+ const errorText = await response.text().catch(() => '');
75
+ return {
76
+ success: false,
77
+ error: `Token endpoint returned ${response.status}: ${errorText}`.trim(),
78
+ };
79
+ }
80
+
81
+ const data = await response.json() as Record<string, unknown>;
82
+ const accessToken = data.access_token;
83
+
84
+ if (typeof accessToken !== 'string') {
85
+ return { success: false, error: 'No access_token in response' };
86
+ }
87
+
88
+ // Store new access token
89
+ const existingAuth = await authManager.retrieve(domain);
90
+ await authManager.store(domain, {
91
+ type: existingAuth?.type ?? 'bearer',
92
+ header: existingAuth?.header ?? 'authorization',
93
+ value: `Bearer ${accessToken}`,
94
+ tokens: existingAuth?.tokens,
95
+ session: existingAuth?.session,
96
+ refreshToken: existingAuth?.refreshToken,
97
+ clientSecret: existingAuth?.clientSecret,
98
+ });
99
+
100
+ // Handle refresh token rotation
101
+ let tokenRotated = false;
102
+ const newRefreshToken = data.refresh_token;
103
+ if (
104
+ typeof newRefreshToken === 'string' &&
105
+ newRefreshToken !== oauthCreds?.refreshToken
106
+ ) {
107
+ await authManager.storeOAuthCredentials(domain, {
108
+ refreshToken: newRefreshToken,
109
+ });
110
+ tokenRotated = true;
111
+ }
112
+
113
+ return { success: true, accessToken, tokenRotated };
114
+ } catch (error) {
115
+ return {
116
+ success: false,
117
+ error: error instanceof Error ? error.message : String(error),
118
+ };
119
+ }
120
+ }