@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.
- package/LICENSE +60 -0
- package/README.md +362 -0
- package/SKILL.md +270 -0
- package/dist/auth/crypto.d.ts +31 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/crypto.js.map +1 -0
- package/dist/auth/handoff.d.ts +29 -0
- package/dist/auth/handoff.js +180 -0
- package/dist/auth/handoff.js.map +1 -0
- package/dist/auth/manager.d.ts +46 -0
- package/dist/auth/manager.js +127 -0
- package/dist/auth/manager.js.map +1 -0
- package/dist/auth/oauth-refresh.d.ts +16 -0
- package/dist/auth/oauth-refresh.js +91 -0
- package/dist/auth/oauth-refresh.js.map +1 -0
- package/dist/auth/refresh.d.ts +43 -0
- package/dist/auth/refresh.js +217 -0
- package/dist/auth/refresh.js.map +1 -0
- package/dist/capture/anti-bot.d.ts +15 -0
- package/dist/capture/anti-bot.js +43 -0
- package/dist/capture/anti-bot.js.map +1 -0
- package/dist/capture/blocklist.d.ts +6 -0
- package/dist/capture/blocklist.js +70 -0
- package/dist/capture/blocklist.js.map +1 -0
- package/dist/capture/body-diff.d.ts +8 -0
- package/dist/capture/body-diff.js +102 -0
- package/dist/capture/body-diff.js.map +1 -0
- package/dist/capture/body-variables.d.ts +13 -0
- package/dist/capture/body-variables.js +142 -0
- package/dist/capture/body-variables.js.map +1 -0
- package/dist/capture/domain.d.ts +8 -0
- package/dist/capture/domain.js +34 -0
- package/dist/capture/domain.js.map +1 -0
- package/dist/capture/entropy.d.ts +33 -0
- package/dist/capture/entropy.js +100 -0
- package/dist/capture/entropy.js.map +1 -0
- package/dist/capture/filter.d.ts +11 -0
- package/dist/capture/filter.js +49 -0
- package/dist/capture/filter.js.map +1 -0
- package/dist/capture/graphql.d.ts +21 -0
- package/dist/capture/graphql.js +99 -0
- package/dist/capture/graphql.js.map +1 -0
- package/dist/capture/idle.d.ts +23 -0
- package/dist/capture/idle.js +44 -0
- package/dist/capture/idle.js.map +1 -0
- package/dist/capture/monitor.d.ts +26 -0
- package/dist/capture/monitor.js +183 -0
- package/dist/capture/monitor.js.map +1 -0
- package/dist/capture/oauth-detector.d.ts +18 -0
- package/dist/capture/oauth-detector.js +96 -0
- package/dist/capture/oauth-detector.js.map +1 -0
- package/dist/capture/pagination.d.ts +9 -0
- package/dist/capture/pagination.js +40 -0
- package/dist/capture/pagination.js.map +1 -0
- package/dist/capture/parameterize.d.ts +17 -0
- package/dist/capture/parameterize.js +63 -0
- package/dist/capture/parameterize.js.map +1 -0
- package/dist/capture/scrubber.d.ts +5 -0
- package/dist/capture/scrubber.js +38 -0
- package/dist/capture/scrubber.js.map +1 -0
- package/dist/capture/session.d.ts +46 -0
- package/dist/capture/session.js +445 -0
- package/dist/capture/session.js.map +1 -0
- package/dist/capture/token-detector.d.ts +16 -0
- package/dist/capture/token-detector.js +62 -0
- package/dist/capture/token-detector.js.map +1 -0
- package/dist/capture/verifier.d.ts +17 -0
- package/dist/capture/verifier.js +147 -0
- package/dist/capture/verifier.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +930 -0
- package/dist/cli.js.map +1 -0
- package/dist/discovery/auth.d.ts +17 -0
- package/dist/discovery/auth.js +81 -0
- package/dist/discovery/auth.js.map +1 -0
- package/dist/discovery/fetch.d.ts +17 -0
- package/dist/discovery/fetch.js +59 -0
- package/dist/discovery/fetch.js.map +1 -0
- package/dist/discovery/frameworks.d.ts +11 -0
- package/dist/discovery/frameworks.js +249 -0
- package/dist/discovery/frameworks.js.map +1 -0
- package/dist/discovery/index.d.ts +21 -0
- package/dist/discovery/index.js +219 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/openapi.d.ts +13 -0
- package/dist/discovery/openapi.js +175 -0
- package/dist/discovery/openapi.js.map +1 -0
- package/dist/discovery/probes.d.ts +9 -0
- package/dist/discovery/probes.js +70 -0
- package/dist/discovery/probes.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/report.d.ts +52 -0
- package/dist/inspect/report.js +191 -0
- package/dist/inspect/report.js.map +1 -0
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.js +526 -0
- package/dist/mcp.js.map +1 -0
- package/dist/orchestration/browse.d.ts +38 -0
- package/dist/orchestration/browse.js +198 -0
- package/dist/orchestration/browse.js.map +1 -0
- package/dist/orchestration/cache.d.ts +15 -0
- package/dist/orchestration/cache.js +24 -0
- package/dist/orchestration/cache.js.map +1 -0
- package/dist/plugin.d.ts +17 -0
- package/dist/plugin.js +158 -0
- package/dist/plugin.js.map +1 -0
- package/dist/read/decoders/deepwiki.d.ts +2 -0
- package/dist/read/decoders/deepwiki.js +148 -0
- package/dist/read/decoders/deepwiki.js.map +1 -0
- package/dist/read/decoders/grokipedia.d.ts +2 -0
- package/dist/read/decoders/grokipedia.js +210 -0
- package/dist/read/decoders/grokipedia.js.map +1 -0
- package/dist/read/decoders/hackernews.d.ts +2 -0
- package/dist/read/decoders/hackernews.js +168 -0
- package/dist/read/decoders/hackernews.js.map +1 -0
- package/dist/read/decoders/index.d.ts +2 -0
- package/dist/read/decoders/index.js +12 -0
- package/dist/read/decoders/index.js.map +1 -0
- package/dist/read/decoders/reddit.d.ts +2 -0
- package/dist/read/decoders/reddit.js +142 -0
- package/dist/read/decoders/reddit.js.map +1 -0
- package/dist/read/decoders/twitter.d.ts +12 -0
- package/dist/read/decoders/twitter.js +187 -0
- package/dist/read/decoders/twitter.js.map +1 -0
- package/dist/read/decoders/wikipedia.d.ts +2 -0
- package/dist/read/decoders/wikipedia.js +66 -0
- package/dist/read/decoders/wikipedia.js.map +1 -0
- package/dist/read/decoders/youtube.d.ts +2 -0
- package/dist/read/decoders/youtube.js +69 -0
- package/dist/read/decoders/youtube.js.map +1 -0
- package/dist/read/extract.d.ts +25 -0
- package/dist/read/extract.js +320 -0
- package/dist/read/extract.js.map +1 -0
- package/dist/read/index.d.ts +14 -0
- package/dist/read/index.js +66 -0
- package/dist/read/index.js.map +1 -0
- package/dist/read/peek.d.ts +9 -0
- package/dist/read/peek.js +137 -0
- package/dist/read/peek.js.map +1 -0
- package/dist/read/types.d.ts +44 -0
- package/dist/read/types.js +3 -0
- package/dist/read/types.js.map +1 -0
- package/dist/replay/engine.d.ts +53 -0
- package/dist/replay/engine.js +441 -0
- package/dist/replay/engine.js.map +1 -0
- package/dist/replay/truncate.d.ts +16 -0
- package/dist/replay/truncate.js +92 -0
- package/dist/replay/truncate.js.map +1 -0
- package/dist/serve.d.ts +31 -0
- package/dist/serve.js +149 -0
- package/dist/serve.js.map +1 -0
- package/dist/skill/generator.d.ts +44 -0
- package/dist/skill/generator.js +419 -0
- package/dist/skill/generator.js.map +1 -0
- package/dist/skill/importer.d.ts +26 -0
- package/dist/skill/importer.js +80 -0
- package/dist/skill/importer.js.map +1 -0
- package/dist/skill/search.d.ts +19 -0
- package/dist/skill/search.js +51 -0
- package/dist/skill/search.js.map +1 -0
- package/dist/skill/signing.d.ts +16 -0
- package/dist/skill/signing.js +34 -0
- package/dist/skill/signing.js.map +1 -0
- package/dist/skill/ssrf.d.ts +27 -0
- package/dist/skill/ssrf.js +210 -0
- package/dist/skill/ssrf.js.map +1 -0
- package/dist/skill/store.d.ts +7 -0
- package/dist/skill/store.js +93 -0
- package/dist/skill/store.js.map +1 -0
- package/dist/stats/report.d.ts +26 -0
- package/dist/stats/report.js +157 -0
- package/dist/stats/report.js.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
- package/src/auth/crypto.ts +92 -0
- package/src/auth/handoff.ts +229 -0
- package/src/auth/manager.ts +140 -0
- package/src/auth/oauth-refresh.ts +120 -0
- package/src/auth/refresh.ts +300 -0
- package/src/capture/anti-bot.ts +63 -0
- package/src/capture/blocklist.ts +75 -0
- package/src/capture/body-diff.ts +109 -0
- package/src/capture/body-variables.ts +156 -0
- package/src/capture/domain.ts +34 -0
- package/src/capture/entropy.ts +121 -0
- package/src/capture/filter.ts +56 -0
- package/src/capture/graphql.ts +124 -0
- package/src/capture/idle.ts +45 -0
- package/src/capture/monitor.ts +224 -0
- package/src/capture/oauth-detector.ts +106 -0
- package/src/capture/pagination.ts +49 -0
- package/src/capture/parameterize.ts +68 -0
- package/src/capture/scrubber.ts +49 -0
- package/src/capture/session.ts +502 -0
- package/src/capture/token-detector.ts +76 -0
- package/src/capture/verifier.ts +171 -0
- package/src/cli.ts +1031 -0
- package/src/discovery/auth.ts +99 -0
- package/src/discovery/fetch.ts +85 -0
- package/src/discovery/frameworks.ts +231 -0
- package/src/discovery/index.ts +256 -0
- package/src/discovery/openapi.ts +230 -0
- package/src/discovery/probes.ts +76 -0
- package/src/index.ts +26 -0
- package/src/inspect/report.ts +247 -0
- package/src/mcp.ts +618 -0
- package/src/orchestration/browse.ts +250 -0
- package/src/orchestration/cache.ts +37 -0
- package/src/plugin.ts +188 -0
- package/src/read/decoders/deepwiki.ts +180 -0
- package/src/read/decoders/grokipedia.ts +246 -0
- package/src/read/decoders/hackernews.ts +198 -0
- package/src/read/decoders/index.ts +15 -0
- package/src/read/decoders/reddit.ts +158 -0
- package/src/read/decoders/twitter.ts +211 -0
- package/src/read/decoders/wikipedia.ts +75 -0
- package/src/read/decoders/youtube.ts +75 -0
- package/src/read/extract.ts +396 -0
- package/src/read/index.ts +78 -0
- package/src/read/peek.ts +175 -0
- package/src/read/types.ts +37 -0
- package/src/replay/engine.ts +559 -0
- package/src/replay/truncate.ts +116 -0
- package/src/serve.ts +189 -0
- package/src/skill/generator.ts +473 -0
- package/src/skill/importer.ts +107 -0
- package/src/skill/search.ts +76 -0
- package/src/skill/signing.ts +36 -0
- package/src/skill/ssrf.ts +238 -0
- package/src/skill/store.ts +107 -0
- package/src/stats/report.ts +208 -0
- 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
|
+
}
|