signet-auth 1.0.0-beta.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.
Potentially problematic release.
This version of signet-auth might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +393 -0
- package/bin/sig.js +65 -0
- package/dist/auth-manager.d.ts +90 -0
- package/dist/auth-manager.js +262 -0
- package/dist/browser/adapters/playwright.adapter.d.ts +14 -0
- package/dist/browser/adapters/playwright.adapter.js +188 -0
- package/dist/browser/flows/form-login.flow.d.ts +6 -0
- package/dist/browser/flows/form-login.flow.js +35 -0
- package/dist/browser/flows/header-capture.d.ts +23 -0
- package/dist/browser/flows/header-capture.js +104 -0
- package/dist/browser/flows/hybrid-flow.d.ts +37 -0
- package/dist/browser/flows/hybrid-flow.js +104 -0
- package/dist/browser/flows/oauth-consent.flow.d.ts +20 -0
- package/dist/browser/flows/oauth-consent.flow.js +170 -0
- package/dist/cli/commands/doctor.d.ts +6 -0
- package/dist/cli/commands/doctor.js +263 -0
- package/dist/cli/commands/get.d.ts +2 -0
- package/dist/cli/commands/get.js +83 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.js +244 -0
- package/dist/cli/commands/login.d.ts +2 -0
- package/dist/cli/commands/login.js +77 -0
- package/dist/cli/commands/logout.d.ts +2 -0
- package/dist/cli/commands/logout.js +11 -0
- package/dist/cli/commands/providers.d.ts +2 -0
- package/dist/cli/commands/providers.js +30 -0
- package/dist/cli/commands/remote.d.ts +1 -0
- package/dist/cli/commands/remote.js +67 -0
- package/dist/cli/commands/request.d.ts +2 -0
- package/dist/cli/commands/request.js +82 -0
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +41 -0
- package/dist/cli/commands/sync.d.ts +2 -0
- package/dist/cli/commands/sync.js +62 -0
- package/dist/cli/formatters.d.ts +3 -0
- package/dist/cli/formatters.js +25 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +125 -0
- package/dist/config/generator.d.ts +24 -0
- package/dist/config/generator.js +97 -0
- package/dist/config/loader.d.ts +21 -0
- package/dist/config/loader.js +54 -0
- package/dist/config/schema.d.ts +44 -0
- package/dist/config/schema.js +8 -0
- package/dist/config/validator.d.ts +15 -0
- package/dist/config/validator.js +228 -0
- package/dist/core/errors.d.ts +57 -0
- package/dist/core/errors.js +107 -0
- package/dist/core/interfaces/auth-strategy.d.ts +48 -0
- package/dist/core/interfaces/auth-strategy.js +1 -0
- package/dist/core/interfaces/browser-adapter.d.ts +73 -0
- package/dist/core/interfaces/browser-adapter.js +1 -0
- package/dist/core/interfaces/provider.d.ts +15 -0
- package/dist/core/interfaces/provider.js +1 -0
- package/dist/core/interfaces/storage.d.ts +21 -0
- package/dist/core/interfaces/storage.js +1 -0
- package/dist/core/result.d.ts +21 -0
- package/dist/core/result.js +16 -0
- package/dist/core/types.d.ts +128 -0
- package/dist/core/types.js +6 -0
- package/dist/deps.d.ts +20 -0
- package/dist/deps.js +54 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +37 -0
- package/dist/providers/auto-provision.d.ts +9 -0
- package/dist/providers/auto-provision.js +27 -0
- package/dist/providers/config-loader.d.ts +7 -0
- package/dist/providers/config-loader.js +7 -0
- package/dist/providers/provider-registry.d.ts +19 -0
- package/dist/providers/provider-registry.js +68 -0
- package/dist/storage/cached-storage.d.ts +24 -0
- package/dist/storage/cached-storage.js +57 -0
- package/dist/storage/directory-storage.d.ts +25 -0
- package/dist/storage/directory-storage.js +184 -0
- package/dist/storage/memory-storage.d.ts +14 -0
- package/dist/storage/memory-storage.js +27 -0
- package/dist/strategies/api-token.strategy.d.ts +6 -0
- package/dist/strategies/api-token.strategy.js +63 -0
- package/dist/strategies/basic-auth.strategy.d.ts +6 -0
- package/dist/strategies/basic-auth.strategy.js +41 -0
- package/dist/strategies/cookie.strategy.d.ts +6 -0
- package/dist/strategies/cookie.strategy.js +118 -0
- package/dist/strategies/oauth2.strategy.d.ts +6 -0
- package/dist/strategies/oauth2.strategy.js +134 -0
- package/dist/strategies/registry.d.ts +13 -0
- package/dist/strategies/registry.js +25 -0
- package/dist/sync/remote-config.d.ts +8 -0
- package/dist/sync/remote-config.js +49 -0
- package/dist/sync/sync-engine.d.ts +10 -0
- package/dist/sync/sync-engine.js +96 -0
- package/dist/sync/transports/ssh.d.ts +18 -0
- package/dist/sync/transports/ssh.js +115 -0
- package/dist/sync/types.d.ts +17 -0
- package/dist/sync/types.js +1 -0
- package/dist/utils/duration.d.ts +9 -0
- package/dist/utils/duration.js +34 -0
- package/dist/utils/http.d.ts +4 -0
- package/dist/utils/http.js +10 -0
- package/dist/utils/jwt.d.ts +15 -0
- package/dist/utils/jwt.js +30 -0
- package/package.json +56 -0
- package/src/auth-manager.ts +331 -0
- package/src/browser/adapters/playwright.adapter.ts +247 -0
- package/src/browser/flows/form-login.flow.ts +35 -0
- package/src/browser/flows/header-capture.ts +128 -0
- package/src/browser/flows/hybrid-flow.ts +165 -0
- package/src/browser/flows/oauth-consent.flow.ts +200 -0
- package/src/cli/commands/doctor.ts +301 -0
- package/src/cli/commands/get.ts +96 -0
- package/src/cli/commands/init.ts +289 -0
- package/src/cli/commands/login.ts +94 -0
- package/src/cli/commands/logout.ts +17 -0
- package/src/cli/commands/providers.ts +39 -0
- package/src/cli/commands/remote.ts +71 -0
- package/src/cli/commands/request.ts +97 -0
- package/src/cli/commands/status.ts +48 -0
- package/src/cli/commands/sync.ts +71 -0
- package/src/cli/formatters.ts +31 -0
- package/src/cli/main.ts +144 -0
- package/src/config/generator.ts +122 -0
- package/src/config/loader.ts +70 -0
- package/src/config/schema.ts +75 -0
- package/src/config/validator.ts +281 -0
- package/src/core/errors.ts +182 -0
- package/src/core/interfaces/auth-strategy.ts +65 -0
- package/src/core/interfaces/browser-adapter.ts +81 -0
- package/src/core/interfaces/provider.ts +19 -0
- package/src/core/interfaces/storage.ts +26 -0
- package/src/core/result.ts +24 -0
- package/src/core/types.ts +194 -0
- package/src/deps.ts +80 -0
- package/src/index.ts +109 -0
- package/src/providers/auto-provision.ts +30 -0
- package/src/providers/config-loader.ts +8 -0
- package/src/providers/provider-registry.ts +79 -0
- package/src/storage/cached-storage.ts +72 -0
- package/src/storage/directory-storage.ts +204 -0
- package/src/storage/memory-storage.ts +35 -0
- package/src/strategies/api-token.strategy.ts +87 -0
- package/src/strategies/basic-auth.strategy.ts +64 -0
- package/src/strategies/cookie.strategy.ts +153 -0
- package/src/strategies/oauth2.strategy.ts +178 -0
- package/src/strategies/registry.ts +34 -0
- package/src/sync/remote-config.ts +60 -0
- package/src/sync/sync-engine.ts +113 -0
- package/src/sync/transports/ssh.ts +130 -0
- package/src/sync/types.ts +15 -0
- package/src/utils/duration.ts +34 -0
- package/src/utils/http.ts +11 -0
- package/src/utils/jwt.ts +39 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createDefaultProvider } from './providers/auto-provision.js';
|
|
2
|
+
import { ok, err, isOk } from './core/result.js';
|
|
3
|
+
import { CredentialTypeError, ProviderNotFoundError, } from './core/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Central orchestrator for authentication lifecycle.
|
|
6
|
+
* All dependencies are injected — no singletons, no global state.
|
|
7
|
+
*
|
|
8
|
+
* Flow: validate → refresh → authenticate
|
|
9
|
+
*/
|
|
10
|
+
export class AuthManager {
|
|
11
|
+
storage;
|
|
12
|
+
strategies;
|
|
13
|
+
providers;
|
|
14
|
+
browserAdapterFactory;
|
|
15
|
+
browserConfig;
|
|
16
|
+
logger;
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
this.storage = deps.storage;
|
|
19
|
+
this.strategies = deps.strategyRegistry;
|
|
20
|
+
this.providers = deps.providerRegistry;
|
|
21
|
+
this.browserAdapterFactory = deps.browserAdapterFactory;
|
|
22
|
+
this.browserConfig = deps.browserConfig;
|
|
23
|
+
this.logger = deps.logger;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get valid credentials for a provider.
|
|
27
|
+
* Tries: stored → refresh → authenticate, in that order.
|
|
28
|
+
*/
|
|
29
|
+
async getCredentials(providerId) {
|
|
30
|
+
const provider = this.providers.get(providerId);
|
|
31
|
+
if (!provider)
|
|
32
|
+
return err(new ProviderNotFoundError(providerId));
|
|
33
|
+
const strategy = this.strategies.get(provider.strategy, provider.strategyConfig);
|
|
34
|
+
const key = this.storageKey(provider);
|
|
35
|
+
// Step 1: Check stored credentials
|
|
36
|
+
const stored = await this.storage.get(key);
|
|
37
|
+
if (stored) {
|
|
38
|
+
const validation = strategy.validate(stored.credential, provider.strategyConfig);
|
|
39
|
+
if (isOk(validation) && validation.value) {
|
|
40
|
+
// Check credential type constraints
|
|
41
|
+
const typeCheck = this.checkCredentialType(provider, stored.credential);
|
|
42
|
+
if (!isOk(typeCheck))
|
|
43
|
+
return typeCheck;
|
|
44
|
+
return ok(stored.credential);
|
|
45
|
+
}
|
|
46
|
+
// Step 2: Try refresh
|
|
47
|
+
this.logger?.debug(`Credentials for "${providerId}" are invalid, attempting refresh...`);
|
|
48
|
+
const refreshResult = await strategy.refresh(stored.credential, provider.strategyConfig);
|
|
49
|
+
if (isOk(refreshResult) && refreshResult.value) {
|
|
50
|
+
const typeCheck = this.checkCredentialType(provider, refreshResult.value);
|
|
51
|
+
if (!isOk(typeCheck))
|
|
52
|
+
return typeCheck;
|
|
53
|
+
await this.store(key, provider.strategy, refreshResult.value);
|
|
54
|
+
return ok(refreshResult.value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Step 3: Full authentication
|
|
58
|
+
this.logger?.info(`Authenticating with "${providerId}"...`);
|
|
59
|
+
const context = {
|
|
60
|
+
browserAdapter: this.browserAdapterFactory(),
|
|
61
|
+
browserConfig: this.browserConfig,
|
|
62
|
+
logger: this.logger,
|
|
63
|
+
};
|
|
64
|
+
const authResult = await strategy.authenticate(provider, context);
|
|
65
|
+
if (!isOk(authResult))
|
|
66
|
+
return authResult;
|
|
67
|
+
const typeCheck = this.checkCredentialType(provider, authResult.value);
|
|
68
|
+
if (!isOk(typeCheck))
|
|
69
|
+
return typeCheck;
|
|
70
|
+
await this.store(key, provider.strategy, authResult.value);
|
|
71
|
+
return ok(authResult.value);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a provider by URL, auto-provisioning a default cookie provider if none matches.
|
|
75
|
+
*/
|
|
76
|
+
resolveProvider(url) {
|
|
77
|
+
const existing = this.providers.resolve(url);
|
|
78
|
+
if (existing)
|
|
79
|
+
return existing;
|
|
80
|
+
const provider = createDefaultProvider(url);
|
|
81
|
+
this.providers.register(provider);
|
|
82
|
+
this.logger?.info(`Auto-provisioned provider "${provider.id}" for ${url}`);
|
|
83
|
+
return provider;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get credentials for a specific provider, resolving by URL.
|
|
87
|
+
*/
|
|
88
|
+
async getCredentialsByUrl(url) {
|
|
89
|
+
const provider = this.resolveProvider(url);
|
|
90
|
+
const result = await this.getCredentials(provider.id);
|
|
91
|
+
if (!isOk(result))
|
|
92
|
+
return result;
|
|
93
|
+
return ok({ provider, credential: result.value });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Force re-authentication, deleting any stored credentials first.
|
|
97
|
+
*/
|
|
98
|
+
async forceReauth(providerId) {
|
|
99
|
+
const provider = this.providers.get(providerId);
|
|
100
|
+
if (provider) {
|
|
101
|
+
await this.storage.delete(this.storageKey(provider));
|
|
102
|
+
}
|
|
103
|
+
return this.getCredentials(providerId);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Store a credential directly (e.g., user-provided API token).
|
|
107
|
+
*/
|
|
108
|
+
async setCredential(providerId, credential) {
|
|
109
|
+
const provider = this.providers.get(providerId);
|
|
110
|
+
if (!provider)
|
|
111
|
+
return err(new ProviderNotFoundError(providerId));
|
|
112
|
+
const typeCheck = this.checkCredentialType(provider, credential);
|
|
113
|
+
if (!isOk(typeCheck))
|
|
114
|
+
return typeCheck;
|
|
115
|
+
await this.store(this.storageKey(provider), provider.strategy, credential);
|
|
116
|
+
return ok(undefined);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get status for a provider (non-triggering — won't start auth).
|
|
120
|
+
*/
|
|
121
|
+
async getStatus(providerId) {
|
|
122
|
+
const provider = this.providers.get(providerId);
|
|
123
|
+
if (!provider) {
|
|
124
|
+
return {
|
|
125
|
+
id: providerId,
|
|
126
|
+
name: providerId,
|
|
127
|
+
configured: false,
|
|
128
|
+
valid: false,
|
|
129
|
+
strategy: 'unknown',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const stored = await this.storage.get(this.storageKey(provider));
|
|
133
|
+
if (!stored) {
|
|
134
|
+
return {
|
|
135
|
+
id: provider.id,
|
|
136
|
+
name: provider.name,
|
|
137
|
+
configured: true,
|
|
138
|
+
valid: false,
|
|
139
|
+
strategy: provider.strategy,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const strategy = this.strategies.get(provider.strategy, provider.strategyConfig);
|
|
143
|
+
const validation = strategy.validate(stored.credential, provider.strategyConfig);
|
|
144
|
+
const valid = isOk(validation) && validation.value;
|
|
145
|
+
const expiresAt = this.getExpiresAt(stored.credential);
|
|
146
|
+
const expiresInMinutes = expiresAt
|
|
147
|
+
? Math.max(0, Math.round((expiresAt.getTime() - Date.now()) / 60000))
|
|
148
|
+
: undefined;
|
|
149
|
+
return {
|
|
150
|
+
id: provider.id,
|
|
151
|
+
name: provider.name,
|
|
152
|
+
configured: true,
|
|
153
|
+
valid,
|
|
154
|
+
credentialType: stored.credential.type,
|
|
155
|
+
strategy: provider.strategy,
|
|
156
|
+
expiresAt: expiresAt?.toISOString(),
|
|
157
|
+
expiresInMinutes,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Get status for all configured providers.
|
|
162
|
+
*/
|
|
163
|
+
async getAllStatus() {
|
|
164
|
+
const providers = this.providers.list();
|
|
165
|
+
return Promise.all(providers.map(p => this.getStatus(p.id)));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Clear stored credentials for a provider.
|
|
169
|
+
*/
|
|
170
|
+
async clearCredentials(providerId) {
|
|
171
|
+
const provider = this.providers.get(providerId);
|
|
172
|
+
const key = provider ? this.storageKey(provider) : providerId;
|
|
173
|
+
await this.storage.delete(key);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Clear all stored credentials.
|
|
177
|
+
*/
|
|
178
|
+
async clearAll() {
|
|
179
|
+
await this.storage.clear();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Apply credentials to an outgoing request (as headers).
|
|
183
|
+
*/
|
|
184
|
+
applyToRequest(providerId, credential) {
|
|
185
|
+
const provider = this.providers.get(providerId);
|
|
186
|
+
if (!provider)
|
|
187
|
+
return {};
|
|
188
|
+
const strategy = this.strategies.get(provider.strategy, provider.strategyConfig);
|
|
189
|
+
return strategy.applyToRequest(credential);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Validate a credential by making a test request to the provider's entry URL.
|
|
193
|
+
* Returns the HTTP status and whether the response redirects to a login page.
|
|
194
|
+
*/
|
|
195
|
+
async validateCredential(provider, credential) {
|
|
196
|
+
if (!provider.entryUrl)
|
|
197
|
+
return { status: null, isLoginRedirect: false };
|
|
198
|
+
try {
|
|
199
|
+
const strategy = this.strategies.get(provider.strategy, provider.strategyConfig);
|
|
200
|
+
const headers = strategy.applyToRequest(credential);
|
|
201
|
+
const response = await fetch(provider.entryUrl, {
|
|
202
|
+
method: 'GET',
|
|
203
|
+
headers: { ...headers, 'User-Agent': 'signet/1.0' },
|
|
204
|
+
redirect: 'manual',
|
|
205
|
+
});
|
|
206
|
+
const location = response.headers.get('location') ?? '';
|
|
207
|
+
const loginPatterns = [
|
|
208
|
+
'/login', '/signin', '/sign-in', '/auth', '/sso', '/oauth',
|
|
209
|
+
'/adfs/', '/saml/', 'login.microsoftonline.com',
|
|
210
|
+
'accounts.google.com',
|
|
211
|
+
];
|
|
212
|
+
const isLoginRedirect = response.status >= 300 && response.status < 400
|
|
213
|
+
&& loginPatterns.some(p => location.toLowerCase().includes(p));
|
|
214
|
+
return { status: response.status, isLoginRedirect };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { status: null, isLoginRedirect: false };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Expose the provider registry for handlers */
|
|
221
|
+
get providerRegistry() {
|
|
222
|
+
return this.providers;
|
|
223
|
+
}
|
|
224
|
+
/** Storage key: uses credentialFile if configured, otherwise provider ID. */
|
|
225
|
+
storageKey(provider) {
|
|
226
|
+
return provider.credentialFile ?? provider.id;
|
|
227
|
+
}
|
|
228
|
+
checkCredentialType(provider, credential) {
|
|
229
|
+
if (provider.acceptedCredentialTypes &&
|
|
230
|
+
provider.acceptedCredentialTypes.length > 0 &&
|
|
231
|
+
!provider.acceptedCredentialTypes.includes(credential.type)) {
|
|
232
|
+
return err(new CredentialTypeError(provider.id, provider.acceptedCredentialTypes, credential.type));
|
|
233
|
+
}
|
|
234
|
+
return ok(undefined);
|
|
235
|
+
}
|
|
236
|
+
async store(providerId, strategy, credential) {
|
|
237
|
+
// Strip transient diagnostics metadata before persisting
|
|
238
|
+
const { __diagnostics, ...clean } = credential;
|
|
239
|
+
const stored = {
|
|
240
|
+
credential: clean,
|
|
241
|
+
providerId,
|
|
242
|
+
strategy,
|
|
243
|
+
updatedAt: new Date().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
await this.storage.set(providerId, stored);
|
|
246
|
+
}
|
|
247
|
+
getExpiresAt(credential) {
|
|
248
|
+
switch (credential.type) {
|
|
249
|
+
case 'bearer':
|
|
250
|
+
return credential.expiresAt ? new Date(credential.expiresAt) : null;
|
|
251
|
+
case 'cookie': {
|
|
252
|
+
// Earliest cookie expiry, or null if all session cookies
|
|
253
|
+
const expiries = credential.cookies
|
|
254
|
+
.filter(c => c.expires > 0)
|
|
255
|
+
.map(c => c.expires * 1000);
|
|
256
|
+
return expiries.length > 0 ? new Date(Math.min(...expiries)) : null;
|
|
257
|
+
}
|
|
258
|
+
default:
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IBrowserAdapter, IBrowserSession } from "../../core/interfaces/browser-adapter.js";
|
|
2
|
+
import type { BrowserLaunchOptions } from "../../core/types.js";
|
|
3
|
+
import type { BrowserConfig } from "../../config/schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* Playwright-based browser adapter.
|
|
6
|
+
* Uses playwright-core (no bundled browsers) — requires a system Chrome/Chromium/Edge.
|
|
7
|
+
* Defaults to system Chrome (channel: 'chrome') when no explicit browser is configured.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PlaywrightAdapter implements IBrowserAdapter {
|
|
10
|
+
private readonly browserConfig;
|
|
11
|
+
readonly name = "playwright";
|
|
12
|
+
constructor(browserConfig: BrowserConfig);
|
|
13
|
+
launch(options: BrowserLaunchOptions): Promise<IBrowserSession>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BrowserLaunchError } from "../../core/errors.js";
|
|
4
|
+
function expandHome(p) {
|
|
5
|
+
if (p.startsWith("~/") || p === "~") {
|
|
6
|
+
return path.join(os.homedir(), p.slice(1));
|
|
7
|
+
}
|
|
8
|
+
return p;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Playwright-based browser adapter.
|
|
12
|
+
* Uses playwright-core (no bundled browsers) — requires a system Chrome/Chromium/Edge.
|
|
13
|
+
* Defaults to system Chrome (channel: 'chrome') when no explicit browser is configured.
|
|
14
|
+
*/
|
|
15
|
+
export class PlaywrightAdapter {
|
|
16
|
+
browserConfig;
|
|
17
|
+
name = "playwright";
|
|
18
|
+
constructor(browserConfig) {
|
|
19
|
+
this.browserConfig = browserConfig;
|
|
20
|
+
}
|
|
21
|
+
async launch(options) {
|
|
22
|
+
const { browserDataDir, channel } = this.browserConfig;
|
|
23
|
+
let pw;
|
|
24
|
+
try {
|
|
25
|
+
pw = await import("playwright-core");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new BrowserLaunchError("playwright-core is not available. Run: npm install playwright-core");
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const context = await pw.chromium.launchPersistentContext(expandHome(browserDataDir), {
|
|
32
|
+
channel: channel,
|
|
33
|
+
headless: options.headless ?? true,
|
|
34
|
+
timeout: options.timeout,
|
|
35
|
+
args: options.args,
|
|
36
|
+
});
|
|
37
|
+
return new PlaywrightSession(context);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
const msg = e.message;
|
|
41
|
+
const hint = msg.includes("executable") || msg.includes("Failed to launch")
|
|
42
|
+
? `${msg}. Ensure a system browser is installed, or check browser.channel in ~/.signet/config.yaml.`
|
|
43
|
+
: msg;
|
|
44
|
+
throw new BrowserLaunchError(hint);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class PlaywrightSession {
|
|
49
|
+
context;
|
|
50
|
+
constructor(context) {
|
|
51
|
+
this.context = context;
|
|
52
|
+
}
|
|
53
|
+
async newPage() {
|
|
54
|
+
const page = await this.context.newPage();
|
|
55
|
+
return new PlaywrightPage(page, this.context);
|
|
56
|
+
}
|
|
57
|
+
async pages() {
|
|
58
|
+
return this.context.pages().map((p) => new PlaywrightPage(p, this.context));
|
|
59
|
+
}
|
|
60
|
+
async close() {
|
|
61
|
+
try {
|
|
62
|
+
await this.context.close();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Suppress close errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
isConnected() {
|
|
69
|
+
return true; // Persistent context doesn't expose isConnected
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
class PlaywrightPage {
|
|
73
|
+
page;
|
|
74
|
+
context;
|
|
75
|
+
constructor(page, context) {
|
|
76
|
+
this.page = page;
|
|
77
|
+
this.context = context;
|
|
78
|
+
}
|
|
79
|
+
async goto(url, options) {
|
|
80
|
+
await this.page.goto(url, {
|
|
81
|
+
waitUntil: options?.waitUntil,
|
|
82
|
+
timeout: options?.timeout,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
url() {
|
|
86
|
+
return this.page.url();
|
|
87
|
+
}
|
|
88
|
+
async waitForUrl(pattern, options) {
|
|
89
|
+
await this.page.waitForURL(pattern, options);
|
|
90
|
+
}
|
|
91
|
+
async waitForNavigation(options) {
|
|
92
|
+
await this.page.waitForLoadState("networkidle", options);
|
|
93
|
+
}
|
|
94
|
+
async waitForLoadState(state) {
|
|
95
|
+
await this.page.waitForLoadState(state);
|
|
96
|
+
}
|
|
97
|
+
async fill(selector, value) {
|
|
98
|
+
await this.page.locator(selector).fill(value);
|
|
99
|
+
}
|
|
100
|
+
async click(selector, options) {
|
|
101
|
+
await this.page.locator(selector).click({ timeout: options?.timeout });
|
|
102
|
+
}
|
|
103
|
+
async type(selector, text, options) {
|
|
104
|
+
await this.page
|
|
105
|
+
.locator(selector)
|
|
106
|
+
.pressSequentially(text, { delay: options?.delay });
|
|
107
|
+
}
|
|
108
|
+
async waitForSelector(selector, options) {
|
|
109
|
+
await this.page.locator(selector).waitFor({
|
|
110
|
+
timeout: options?.timeout,
|
|
111
|
+
state: options?.state,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async cookies(urls) {
|
|
115
|
+
const rawCookies = await this.context.cookies(urls);
|
|
116
|
+
return rawCookies.map((c) => ({
|
|
117
|
+
name: c.name,
|
|
118
|
+
value: c.value,
|
|
119
|
+
domain: c.domain,
|
|
120
|
+
path: c.path,
|
|
121
|
+
expires: c.expires,
|
|
122
|
+
httpOnly: c.httpOnly,
|
|
123
|
+
secure: c.secure,
|
|
124
|
+
sameSite: c.sameSite === "Strict"
|
|
125
|
+
? "Strict"
|
|
126
|
+
: c.sameSite === "Lax"
|
|
127
|
+
? "Lax"
|
|
128
|
+
: c.sameSite === "None"
|
|
129
|
+
? "None"
|
|
130
|
+
: undefined,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
async evaluate(fn) {
|
|
134
|
+
if (typeof fn === "string") {
|
|
135
|
+
return (await this.page.evaluate(fn));
|
|
136
|
+
}
|
|
137
|
+
return await this.page.evaluate(fn);
|
|
138
|
+
}
|
|
139
|
+
async evaluateWithArg(fn, arg) {
|
|
140
|
+
return await this.page.evaluate(fn, arg);
|
|
141
|
+
}
|
|
142
|
+
async screenshot(options) {
|
|
143
|
+
return Buffer.from(await this.page.screenshot(options));
|
|
144
|
+
}
|
|
145
|
+
async content() {
|
|
146
|
+
return await this.page.content();
|
|
147
|
+
}
|
|
148
|
+
async title() {
|
|
149
|
+
return await this.page.title();
|
|
150
|
+
}
|
|
151
|
+
async close() {
|
|
152
|
+
if (!this.page.isClosed()) {
|
|
153
|
+
await this.page.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
isClosed() {
|
|
157
|
+
return this.page.isClosed();
|
|
158
|
+
}
|
|
159
|
+
onClose(handler) {
|
|
160
|
+
this.page.on("close", handler);
|
|
161
|
+
}
|
|
162
|
+
onRequest(handler) {
|
|
163
|
+
const listener = (req) => {
|
|
164
|
+
handler({
|
|
165
|
+
url: req.url(),
|
|
166
|
+
method: req.method(),
|
|
167
|
+
headers: req.headers(),
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
this.page.on("request", listener);
|
|
171
|
+
return () => {
|
|
172
|
+
this.page.off("request", listener);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
onResponse(handler) {
|
|
176
|
+
const listener = (res) => {
|
|
177
|
+
handler({
|
|
178
|
+
url: res.url(),
|
|
179
|
+
status: res.status(),
|
|
180
|
+
headers: res.headers(),
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
this.page.on("response", listener);
|
|
184
|
+
return () => {
|
|
185
|
+
this.page.off("response", listener);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Detect and check for common login form patterns.
|
|
4
|
+
* Returns true if the page appears to be a login page.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLoginPage(page: IBrowserPage): Promise<boolean>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect and check for common login form patterns.
|
|
3
|
+
* Returns true if the page appears to be a login page.
|
|
4
|
+
*/
|
|
5
|
+
export async function isLoginPage(page) {
|
|
6
|
+
try {
|
|
7
|
+
const url = page.url().toLowerCase();
|
|
8
|
+
const loginUrlPatterns = [
|
|
9
|
+
'/login', '/signin', '/sign-in', '/auth', '/sso', '/oauth',
|
|
10
|
+
'/adfs/', '/saml/',
|
|
11
|
+
'login.microsoftonline.com', 'accounts.google.com',
|
|
12
|
+
];
|
|
13
|
+
if (loginUrlPatterns.some(p => url.includes(p)))
|
|
14
|
+
return true;
|
|
15
|
+
// Check for common form elements
|
|
16
|
+
const hasLoginForm = await page.evaluate(() => {
|
|
17
|
+
const inputs = document.querySelectorAll('input');
|
|
18
|
+
let hasPassword = false;
|
|
19
|
+
let hasEmail = false;
|
|
20
|
+
for (const input of inputs) {
|
|
21
|
+
const type = input.type.toLowerCase();
|
|
22
|
+
const name = (input.name || input.id || '').toLowerCase();
|
|
23
|
+
if (type === 'password')
|
|
24
|
+
hasPassword = true;
|
|
25
|
+
if (type === 'email' || name.includes('email') || name.includes('user'))
|
|
26
|
+
hasEmail = true;
|
|
27
|
+
}
|
|
28
|
+
return hasPassword || hasEmail;
|
|
29
|
+
});
|
|
30
|
+
return hasLoginForm;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
import type { XHeaderConfig } from '../../core/types.js';
|
|
3
|
+
export interface HeaderCaptureResult {
|
|
4
|
+
/** Captured headers (keyed by original config name, values last-write-wins) */
|
|
5
|
+
xHeaders: Record<string, string>;
|
|
6
|
+
/** Call to remove network listeners */
|
|
7
|
+
cleanup: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Start capturing HTTP headers from browser network traffic.
|
|
11
|
+
*
|
|
12
|
+
* - Static values are populated immediately.
|
|
13
|
+
* - Dynamic values are captured via onRequest/onResponse listeners.
|
|
14
|
+
* - Filters by providerDomains and optional urlPattern per config entry.
|
|
15
|
+
* - Header name matching is case-insensitive.
|
|
16
|
+
* - Gracefully degrades if the page doesn't support onRequest/onResponse.
|
|
17
|
+
*
|
|
18
|
+
* @param page Browser page to observe
|
|
19
|
+
* @param configs Header capture configurations
|
|
20
|
+
* @param providerDomains Domains belonging to the provider (used as URL filter)
|
|
21
|
+
* @returns xHeaders record and a cleanup function
|
|
22
|
+
*/
|
|
23
|
+
export declare function startHeaderCapture(page: IBrowserPage, configs: XHeaderConfig[], providerDomains: string[]): HeaderCaptureResult;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start capturing HTTP headers from browser network traffic.
|
|
3
|
+
*
|
|
4
|
+
* - Static values are populated immediately.
|
|
5
|
+
* - Dynamic values are captured via onRequest/onResponse listeners.
|
|
6
|
+
* - Filters by providerDomains and optional urlPattern per config entry.
|
|
7
|
+
* - Header name matching is case-insensitive.
|
|
8
|
+
* - Gracefully degrades if the page doesn't support onRequest/onResponse.
|
|
9
|
+
*
|
|
10
|
+
* @param page Browser page to observe
|
|
11
|
+
* @param configs Header capture configurations
|
|
12
|
+
* @param providerDomains Domains belonging to the provider (used as URL filter)
|
|
13
|
+
* @returns xHeaders record and a cleanup function
|
|
14
|
+
*/
|
|
15
|
+
export function startHeaderCapture(page, configs, providerDomains) {
|
|
16
|
+
const xHeaders = {};
|
|
17
|
+
const cleanups = [];
|
|
18
|
+
// Separate static vs dynamic configs
|
|
19
|
+
const dynamicConfigs = [];
|
|
20
|
+
for (const cfg of configs) {
|
|
21
|
+
if (cfg.staticValue !== undefined) {
|
|
22
|
+
xHeaders[cfg.name] = cfg.staticValue;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
dynamicConfigs.push(cfg);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// If no dynamic configs or page doesn't support interception, return early
|
|
29
|
+
if (dynamicConfigs.length === 0) {
|
|
30
|
+
return { xHeaders, cleanup: () => { } };
|
|
31
|
+
}
|
|
32
|
+
// Build a set of request-source and response-source configs
|
|
33
|
+
const requestConfigs = dynamicConfigs.filter(c => !c.source || c.source === 'request');
|
|
34
|
+
const responseConfigs = dynamicConfigs.filter(c => !c.source || c.source === 'response');
|
|
35
|
+
// Helper: check if a URL matches the provider domains and optional urlPattern
|
|
36
|
+
function matchesUrl(url, urlPattern) {
|
|
37
|
+
// Must match at least one provider domain
|
|
38
|
+
let domainMatch = false;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
for (const domain of providerDomains) {
|
|
42
|
+
// Support wildcard domains like *.example.com
|
|
43
|
+
if (domain.startsWith('*.')) {
|
|
44
|
+
const suffix = domain.slice(1); // .example.com
|
|
45
|
+
if (parsed.hostname.endsWith(suffix) || parsed.hostname === domain.slice(2)) {
|
|
46
|
+
domainMatch = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (parsed.hostname === domain) {
|
|
51
|
+
domainMatch = true;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (!domainMatch)
|
|
60
|
+
return false;
|
|
61
|
+
// Optional urlPattern filter (simple substring match on full URL)
|
|
62
|
+
if (urlPattern) {
|
|
63
|
+
return url.includes(urlPattern);
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// Helper: extract matching headers from a headers record
|
|
68
|
+
function extractHeaders(configs, url, headers) {
|
|
69
|
+
for (const cfg of configs) {
|
|
70
|
+
if (!matchesUrl(url, cfg.urlPattern))
|
|
71
|
+
continue;
|
|
72
|
+
// Case-insensitive header lookup
|
|
73
|
+
const lowerName = cfg.name.toLowerCase();
|
|
74
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
75
|
+
if (key.toLowerCase() === lowerName) {
|
|
76
|
+
xHeaders[cfg.name] = value;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Set up request listener
|
|
83
|
+
if (requestConfigs.length > 0 && page.onRequest) {
|
|
84
|
+
const unsub = page.onRequest((req) => {
|
|
85
|
+
extractHeaders(requestConfigs, req.url, req.headers);
|
|
86
|
+
});
|
|
87
|
+
cleanups.push(unsub);
|
|
88
|
+
}
|
|
89
|
+
// Set up response listener
|
|
90
|
+
if (responseConfigs.length > 0 && page.onResponse) {
|
|
91
|
+
const unsub = page.onResponse((res) => {
|
|
92
|
+
extractHeaders(responseConfigs, res.url, res.headers);
|
|
93
|
+
});
|
|
94
|
+
cleanups.push(unsub);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
xHeaders,
|
|
98
|
+
cleanup: () => {
|
|
99
|
+
for (const fn of cleanups) {
|
|
100
|
+
fn();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IBrowserAdapter, IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
import type { XHeaderConfig } from '../../core/types.js';
|
|
3
|
+
import type { BrowserConfig } from '../../config/schema.js';
|
|
4
|
+
import type { Result } from '../../core/result.js';
|
|
5
|
+
import { type AuthError } from '../../core/errors.js';
|
|
6
|
+
export interface HybridFlowOptions {
|
|
7
|
+
entryUrl: string;
|
|
8
|
+
/** Called on each page to check if auth is complete */
|
|
9
|
+
isAuthenticated: (page: IBrowserPage) => Promise<boolean>;
|
|
10
|
+
/** Called once auth is detected to extract credentials */
|
|
11
|
+
extractCredentials: (page: IBrowserPage, xHeaders?: Record<string, string>, meta?: {
|
|
12
|
+
immediateAuth: boolean;
|
|
13
|
+
}) => Promise<Result<unknown, AuthError>>;
|
|
14
|
+
/** Global browser config (timeouts, waitUntil defaults) */
|
|
15
|
+
browserConfig: BrowserConfig;
|
|
16
|
+
/** Skip headless, go straight to visible (from provider config) */
|
|
17
|
+
forceVisible?: boolean;
|
|
18
|
+
/** Strategy-specific waitUntil override (e.g. cookie strategy uses 'networkidle') */
|
|
19
|
+
waitUntil?: 'load' | 'networkidle' | 'domcontentloaded' | 'commit';
|
|
20
|
+
/** Custom browser launch args */
|
|
21
|
+
browserArgs?: string[];
|
|
22
|
+
/** X-header configs — extra HTTP headers to capture during browser auth */
|
|
23
|
+
xHeaders?: XHeaderConfig[];
|
|
24
|
+
/** Provider domains used for filtering x-header capture */
|
|
25
|
+
providerDomains?: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Hybrid browser flow: headless → visible fallback.
|
|
29
|
+
*
|
|
30
|
+
* 1. Tries headless browser first (fast, no user interaction)
|
|
31
|
+
* 2. If headless fails (timeout, CAPTCHA, cert dialog), switches to visible
|
|
32
|
+
* 3. In visible mode, waits for user to complete login manually
|
|
33
|
+
* 4. Extracts credentials once authenticated
|
|
34
|
+
*
|
|
35
|
+
* This flow is adapter-agnostic — works with any IBrowserAdapter.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runHybridFlow<T>(adapter: IBrowserAdapter, options: HybridFlowOptions): Promise<Result<T, AuthError>>;
|