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,35 @@
|
|
|
1
|
+
import type { IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect and check for common login form patterns.
|
|
5
|
+
* Returns true if the page appears to be a login page.
|
|
6
|
+
*/
|
|
7
|
+
export async function isLoginPage(page: IBrowserPage): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
const url = page.url().toLowerCase();
|
|
10
|
+
const loginUrlPatterns = [
|
|
11
|
+
'/login', '/signin', '/sign-in', '/auth', '/sso', '/oauth',
|
|
12
|
+
'/adfs/', '/saml/',
|
|
13
|
+
'login.microsoftonline.com', 'accounts.google.com',
|
|
14
|
+
];
|
|
15
|
+
if (loginUrlPatterns.some(p => url.includes(p))) return true;
|
|
16
|
+
|
|
17
|
+
// Check for common form elements
|
|
18
|
+
const hasLoginForm = await page.evaluate(() => {
|
|
19
|
+
const inputs = document.querySelectorAll('input');
|
|
20
|
+
let hasPassword = false;
|
|
21
|
+
let hasEmail = false;
|
|
22
|
+
for (const input of inputs) {
|
|
23
|
+
const type = input.type.toLowerCase();
|
|
24
|
+
const name = (input.name || input.id || '').toLowerCase();
|
|
25
|
+
if (type === 'password') hasPassword = true;
|
|
26
|
+
if (type === 'email' || name.includes('email') || name.includes('user')) hasEmail = true;
|
|
27
|
+
}
|
|
28
|
+
return hasPassword || hasEmail;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return hasLoginForm;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
import type { XHeaderConfig } from '../../core/types.js';
|
|
3
|
+
|
|
4
|
+
export interface HeaderCaptureResult {
|
|
5
|
+
/** Captured headers (keyed by original config name, values last-write-wins) */
|
|
6
|
+
xHeaders: Record<string, string>;
|
|
7
|
+
/** Call to remove network listeners */
|
|
8
|
+
cleanup: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start capturing HTTP headers from browser network traffic.
|
|
13
|
+
*
|
|
14
|
+
* - Static values are populated immediately.
|
|
15
|
+
* - Dynamic values are captured via onRequest/onResponse listeners.
|
|
16
|
+
* - Filters by providerDomains and optional urlPattern per config entry.
|
|
17
|
+
* - Header name matching is case-insensitive.
|
|
18
|
+
* - Gracefully degrades if the page doesn't support onRequest/onResponse.
|
|
19
|
+
*
|
|
20
|
+
* @param page Browser page to observe
|
|
21
|
+
* @param configs Header capture configurations
|
|
22
|
+
* @param providerDomains Domains belonging to the provider (used as URL filter)
|
|
23
|
+
* @returns xHeaders record and a cleanup function
|
|
24
|
+
*/
|
|
25
|
+
export function startHeaderCapture(
|
|
26
|
+
page: IBrowserPage,
|
|
27
|
+
configs: XHeaderConfig[],
|
|
28
|
+
providerDomains: string[],
|
|
29
|
+
): HeaderCaptureResult {
|
|
30
|
+
const xHeaders: Record<string, string> = {};
|
|
31
|
+
const cleanups: Array<() => void> = [];
|
|
32
|
+
|
|
33
|
+
// Separate static vs dynamic configs
|
|
34
|
+
const dynamicConfigs: XHeaderConfig[] = [];
|
|
35
|
+
|
|
36
|
+
for (const cfg of configs) {
|
|
37
|
+
if (cfg.staticValue !== undefined) {
|
|
38
|
+
xHeaders[cfg.name] = cfg.staticValue;
|
|
39
|
+
} else {
|
|
40
|
+
dynamicConfigs.push(cfg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If no dynamic configs or page doesn't support interception, return early
|
|
45
|
+
if (dynamicConfigs.length === 0) {
|
|
46
|
+
return { xHeaders, cleanup: () => {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Build a set of request-source and response-source configs
|
|
50
|
+
const requestConfigs = dynamicConfigs.filter(c => !c.source || c.source === 'request');
|
|
51
|
+
const responseConfigs = dynamicConfigs.filter(c => !c.source || c.source === 'response');
|
|
52
|
+
|
|
53
|
+
// Helper: check if a URL matches the provider domains and optional urlPattern
|
|
54
|
+
function matchesUrl(url: string, urlPattern?: string): boolean {
|
|
55
|
+
// Must match at least one provider domain
|
|
56
|
+
let domainMatch = false;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(url);
|
|
59
|
+
for (const domain of providerDomains) {
|
|
60
|
+
// Support wildcard domains like *.example.com
|
|
61
|
+
if (domain.startsWith('*.')) {
|
|
62
|
+
const suffix = domain.slice(1); // .example.com
|
|
63
|
+
if (parsed.hostname.endsWith(suffix) || parsed.hostname === domain.slice(2)) {
|
|
64
|
+
domainMatch = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
} else if (parsed.hostname === domain) {
|
|
68
|
+
domainMatch = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (!domainMatch) return false;
|
|
76
|
+
|
|
77
|
+
// Optional urlPattern filter (simple substring match on full URL)
|
|
78
|
+
if (urlPattern) {
|
|
79
|
+
return url.includes(urlPattern);
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Helper: extract matching headers from a headers record
|
|
85
|
+
function extractHeaders(
|
|
86
|
+
configs: XHeaderConfig[],
|
|
87
|
+
url: string,
|
|
88
|
+
headers: Record<string, string>,
|
|
89
|
+
): void {
|
|
90
|
+
for (const cfg of configs) {
|
|
91
|
+
if (!matchesUrl(url, cfg.urlPattern)) continue;
|
|
92
|
+
|
|
93
|
+
// Case-insensitive header lookup
|
|
94
|
+
const lowerName = cfg.name.toLowerCase();
|
|
95
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
96
|
+
if (key.toLowerCase() === lowerName) {
|
|
97
|
+
xHeaders[cfg.name] = value;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Set up request listener
|
|
105
|
+
if (requestConfigs.length > 0 && page.onRequest) {
|
|
106
|
+
const unsub = page.onRequest((req) => {
|
|
107
|
+
extractHeaders(requestConfigs, req.url, req.headers);
|
|
108
|
+
});
|
|
109
|
+
cleanups.push(unsub);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Set up response listener
|
|
113
|
+
if (responseConfigs.length > 0 && page.onResponse) {
|
|
114
|
+
const unsub = page.onResponse((res) => {
|
|
115
|
+
extractHeaders(responseConfigs, res.url, res.headers);
|
|
116
|
+
});
|
|
117
|
+
cleanups.push(unsub);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
xHeaders,
|
|
122
|
+
cleanup: () => {
|
|
123
|
+
for (const fn of cleanups) {
|
|
124
|
+
fn();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { IBrowserAdapter, IBrowserPage, IBrowserSession } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
import type { BrowserLaunchOptions, XHeaderConfig } from '../../core/types.js';
|
|
3
|
+
import type { BrowserConfig } from '../../config/schema.js';
|
|
4
|
+
import type { Result } from '../../core/result.js';
|
|
5
|
+
import { ok, err } from '../../core/result.js';
|
|
6
|
+
import { BrowserError, BrowserTimeoutError, type AuthError } from '../../core/errors.js';
|
|
7
|
+
import { startHeaderCapture } from './header-capture.js';
|
|
8
|
+
|
|
9
|
+
export interface HybridFlowOptions {
|
|
10
|
+
entryUrl: string;
|
|
11
|
+
/** Called on each page to check if auth is complete */
|
|
12
|
+
isAuthenticated: (page: IBrowserPage) => Promise<boolean>;
|
|
13
|
+
/** Called once auth is detected to extract credentials */
|
|
14
|
+
extractCredentials: (page: IBrowserPage, xHeaders?: Record<string, string>, meta?: { immediateAuth: boolean }) => Promise<Result<unknown, AuthError>>;
|
|
15
|
+
/** Global browser config (timeouts, waitUntil defaults) */
|
|
16
|
+
browserConfig: BrowserConfig;
|
|
17
|
+
/** Skip headless, go straight to visible (from provider config) */
|
|
18
|
+
forceVisible?: boolean;
|
|
19
|
+
/** Strategy-specific waitUntil override (e.g. cookie strategy uses 'networkidle') */
|
|
20
|
+
waitUntil?: 'load' | 'networkidle' | 'domcontentloaded' | 'commit';
|
|
21
|
+
/** Custom browser launch args */
|
|
22
|
+
browserArgs?: string[];
|
|
23
|
+
/** X-header configs — extra HTTP headers to capture during browser auth */
|
|
24
|
+
xHeaders?: XHeaderConfig[];
|
|
25
|
+
/** Provider domains used for filtering x-header capture */
|
|
26
|
+
providerDomains?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hybrid browser flow: headless → visible fallback.
|
|
31
|
+
*
|
|
32
|
+
* 1. Tries headless browser first (fast, no user interaction)
|
|
33
|
+
* 2. If headless fails (timeout, CAPTCHA, cert dialog), switches to visible
|
|
34
|
+
* 3. In visible mode, waits for user to complete login manually
|
|
35
|
+
* 4. Extracts credentials once authenticated
|
|
36
|
+
*
|
|
37
|
+
* This flow is adapter-agnostic — works with any IBrowserAdapter.
|
|
38
|
+
*/
|
|
39
|
+
export async function runHybridFlow<T>(
|
|
40
|
+
adapter: IBrowserAdapter,
|
|
41
|
+
options: HybridFlowOptions,
|
|
42
|
+
): Promise<Result<T, AuthError>> {
|
|
43
|
+
const { headlessTimeout, visibleTimeout } = options.browserConfig;
|
|
44
|
+
|
|
45
|
+
// Phase 1: Try headless (unless forceVisible)
|
|
46
|
+
if (!options.forceVisible) {
|
|
47
|
+
const headlessResult = await attemptAuth<T>(adapter, {
|
|
48
|
+
...options,
|
|
49
|
+
headless: true,
|
|
50
|
+
timeout: headlessTimeout,
|
|
51
|
+
});
|
|
52
|
+
if (headlessResult.ok) return headlessResult;
|
|
53
|
+
|
|
54
|
+
console.error(
|
|
55
|
+
`[signet] Headless auth failed: ${headlessResult.error.message}. Switching to visible mode...`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Phase 2: Visible mode (user-assisted)
|
|
60
|
+
console.error('[signet] Please complete login in the browser window...');
|
|
61
|
+
return await attemptAuth<T>(adapter, {
|
|
62
|
+
...options,
|
|
63
|
+
headless: false,
|
|
64
|
+
timeout: visibleTimeout,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function attemptAuth<T>(
|
|
69
|
+
adapter: IBrowserAdapter,
|
|
70
|
+
options: HybridFlowOptions & { headless: boolean; timeout: number },
|
|
71
|
+
): Promise<Result<T, AuthError>> {
|
|
72
|
+
let session: IBrowserSession | undefined;
|
|
73
|
+
let headerCleanup: (() => void) | undefined;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const launchOptions: BrowserLaunchOptions = {
|
|
77
|
+
headless: options.headless,
|
|
78
|
+
timeout: options.timeout,
|
|
79
|
+
args: options.browserArgs,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
session = await adapter.launch(launchOptions);
|
|
83
|
+
const page = await session.newPage();
|
|
84
|
+
|
|
85
|
+
// Set up x-header capture before navigation (so we capture all traffic)
|
|
86
|
+
let xHeaders: Record<string, string> | undefined;
|
|
87
|
+
if (options.xHeaders && options.xHeaders.length > 0) {
|
|
88
|
+
const capture = startHeaderCapture(
|
|
89
|
+
page,
|
|
90
|
+
options.xHeaders,
|
|
91
|
+
options.providerDomains ?? [],
|
|
92
|
+
);
|
|
93
|
+
xHeaders = capture.xHeaders;
|
|
94
|
+
headerCleanup = capture.cleanup;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Navigate to entry URL
|
|
98
|
+
// Strategy can override global waitUntil (e.g. cookie forces 'networkidle')
|
|
99
|
+
const waitUntil = options.waitUntil ?? options.browserConfig.waitUntil;
|
|
100
|
+
await page.goto(options.entryUrl, {
|
|
101
|
+
waitUntil,
|
|
102
|
+
timeout: options.timeout,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Brief pause to let any client-side redirects start
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
107
|
+
|
|
108
|
+
// Check if already authenticated (cached session/cookies)
|
|
109
|
+
if (await options.isAuthenticated(page)) {
|
|
110
|
+
const result = await options.extractCredentials(page, xHeaders, { immediateAuth: true });
|
|
111
|
+
return result as Result<T, AuthError>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Wait for authentication to complete (polling)
|
|
115
|
+
const authenticated = await pollForAuth(
|
|
116
|
+
page,
|
|
117
|
+
options.isAuthenticated,
|
|
118
|
+
options.timeout,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!authenticated) {
|
|
122
|
+
return err(
|
|
123
|
+
new BrowserTimeoutError(
|
|
124
|
+
'waiting for authentication',
|
|
125
|
+
options.timeout,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await options.extractCredentials(page, xHeaders, { immediateAuth: false });
|
|
131
|
+
return result as Result<T, AuthError>;
|
|
132
|
+
} catch (e: unknown) {
|
|
133
|
+
if (e instanceof BrowserTimeoutError) {
|
|
134
|
+
return err(e);
|
|
135
|
+
}
|
|
136
|
+
return err(new BrowserError((e as Error).message));
|
|
137
|
+
} finally {
|
|
138
|
+
if (headerCleanup) {
|
|
139
|
+
headerCleanup();
|
|
140
|
+
}
|
|
141
|
+
if (session) {
|
|
142
|
+
await session.close().catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function pollForAuth(
|
|
148
|
+
page: IBrowserPage,
|
|
149
|
+
isAuthenticated: (page: IBrowserPage) => Promise<boolean>,
|
|
150
|
+
timeoutMs: number,
|
|
151
|
+
): Promise<boolean> {
|
|
152
|
+
const pollInterval = 2_000;
|
|
153
|
+
const deadline = Date.now() + timeoutMs;
|
|
154
|
+
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
try {
|
|
157
|
+
if (await isAuthenticated(page)) return true;
|
|
158
|
+
} catch {
|
|
159
|
+
// Page might be navigating — ignore and retry
|
|
160
|
+
}
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { IBrowserPage } from '../../core/interfaces/browser-adapter.js';
|
|
2
|
+
import type { BearerCredential } from '../../core/types.js';
|
|
3
|
+
import type { Result } from '../../core/result.js';
|
|
4
|
+
import { ok, err } from '../../core/result.js';
|
|
5
|
+
import { BrowserError, type AuthError } from '../../core/errors.js';
|
|
6
|
+
import { decodeJwt, getJwtExpiresAt } from '../../utils/jwt.js';
|
|
7
|
+
|
|
8
|
+
const EXPIRY_BUFFER_MS = 60_000; // 1 minute buffer
|
|
9
|
+
|
|
10
|
+
/** Returns true if the JWT payload has a valid (non-expired) exp claim. */
|
|
11
|
+
function isTokenValid(payload: ReturnType<typeof decodeJwt>): boolean {
|
|
12
|
+
if (!payload?.exp) return false;
|
|
13
|
+
return payload.exp * 1000 > Date.now() + EXPIRY_BUFFER_MS;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract OAuth tokens from browser localStorage.
|
|
18
|
+
* Searches for JWT-like strings and validates them against expected audiences.
|
|
19
|
+
*/
|
|
20
|
+
export async function extractOAuthTokens(
|
|
21
|
+
page: IBrowserPage,
|
|
22
|
+
options?: {
|
|
23
|
+
audiences?: string[];
|
|
24
|
+
extractRefreshToken?: boolean;
|
|
25
|
+
/** Max retries to wait for tokens to appear (default: 5, 2s apart) */
|
|
26
|
+
maxRetries?: number;
|
|
27
|
+
},
|
|
28
|
+
): Promise<Result<BearerCredential, AuthError>> {
|
|
29
|
+
const maxRetries = options?.maxRetries ?? 5;
|
|
30
|
+
|
|
31
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
32
|
+
const result = await tryExtractTokens(page, options);
|
|
33
|
+
if (result.ok) return result;
|
|
34
|
+
|
|
35
|
+
// If this isn't the last attempt, wait and retry (MSAL may still be writing tokens)
|
|
36
|
+
if (attempt < maxRetries) {
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
38
|
+
} else {
|
|
39
|
+
return result; // Return the last error
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return err(new BrowserError('Token extraction exhausted all retries.'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if OAuth tokens (JWTs) exist in browser storage.
|
|
48
|
+
* Used as a guard to ensure MSAL has finished writing tokens before
|
|
49
|
+
* the browser is closed.
|
|
50
|
+
*/
|
|
51
|
+
export async function hasOAuthTokens(
|
|
52
|
+
page: IBrowserPage,
|
|
53
|
+
audiences?: string[],
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
const storage = await page.evaluate(() => {
|
|
57
|
+
const entries: Record<string, string> = {};
|
|
58
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
59
|
+
const key = localStorage.key(i);
|
|
60
|
+
if (key) entries['local:' + key] = localStorage.getItem(key) ?? '';
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
63
|
+
const key = sessionStorage.key(i);
|
|
64
|
+
if (key) entries['session:' + key] = sessionStorage.getItem(key) ?? '';
|
|
65
|
+
}
|
|
66
|
+
return entries;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
70
|
+
const tokens: string[] = [];
|
|
71
|
+
|
|
72
|
+
for (const value of Object.values(storage)) {
|
|
73
|
+
const matches = value.match(jwtRegex);
|
|
74
|
+
if (matches) tokens.push(...matches);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (tokens.length === 0) return false;
|
|
78
|
+
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
const payload = decodeJwt(token);
|
|
81
|
+
if (!payload || !isTokenValid(payload)) continue;
|
|
82
|
+
|
|
83
|
+
if (audiences && audiences.length > 0) {
|
|
84
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
85
|
+
if (audiences.some(a => aud.includes(a))) return true;
|
|
86
|
+
} else {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function tryExtractTokens(
|
|
98
|
+
page: IBrowserPage,
|
|
99
|
+
options?: {
|
|
100
|
+
audiences?: string[];
|
|
101
|
+
extractRefreshToken?: boolean;
|
|
102
|
+
},
|
|
103
|
+
): Promise<Result<BearerCredential, AuthError>> {
|
|
104
|
+
try {
|
|
105
|
+
// Extract all entries from both localStorage and sessionStorage
|
|
106
|
+
const storage = await page.evaluate(() => {
|
|
107
|
+
const entries: Record<string, string> = {};
|
|
108
|
+
// Check localStorage
|
|
109
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
110
|
+
const key = localStorage.key(i);
|
|
111
|
+
if (key) entries['local:' + key] = localStorage.getItem(key) ?? '';
|
|
112
|
+
}
|
|
113
|
+
// Check sessionStorage
|
|
114
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
115
|
+
const key = sessionStorage.key(i);
|
|
116
|
+
if (key) entries['session:' + key] = sessionStorage.getItem(key) ?? '';
|
|
117
|
+
}
|
|
118
|
+
return entries;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Find JWTs in localStorage values
|
|
122
|
+
const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
123
|
+
const tokens: string[] = [];
|
|
124
|
+
|
|
125
|
+
for (const value of Object.values(storage)) {
|
|
126
|
+
const matches = value.match(jwtRegex);
|
|
127
|
+
if (matches) tokens.push(...matches);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (tokens.length === 0) {
|
|
131
|
+
return err(new BrowserError('No OAuth tokens found in browser localStorage.'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Find the best matching token: must be non-expired, match audience if specified,
|
|
135
|
+
// and prefer the one with the latest expiry.
|
|
136
|
+
let bestToken: string | undefined;
|
|
137
|
+
let bestPayload: ReturnType<typeof decodeJwt> | undefined;
|
|
138
|
+
|
|
139
|
+
for (const token of tokens) {
|
|
140
|
+
const payload = decodeJwt(token);
|
|
141
|
+
if (!payload || !isTokenValid(payload)) continue;
|
|
142
|
+
|
|
143
|
+
if (options?.audiences && options.audiences.length > 0) {
|
|
144
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
145
|
+
if (!options.audiences.some(a => aud.includes(a))) continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Prefer the token with the latest expiry
|
|
149
|
+
if (!bestPayload || (payload.exp ?? 0) > (bestPayload.exp ?? 0)) {
|
|
150
|
+
bestToken = token;
|
|
151
|
+
bestPayload = payload;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!bestToken) {
|
|
156
|
+
return err(new BrowserError(
|
|
157
|
+
`No valid (non-expired) token matching audiences [${options?.audiences?.join(', ')}] found. ` +
|
|
158
|
+
`Found ${tokens.length} token(s) in storage.`,
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Extract refresh token (MSAL format: keys containing "refreshtoken")
|
|
163
|
+
let refreshToken: string | undefined;
|
|
164
|
+
if (options?.extractRefreshToken) {
|
|
165
|
+
for (const [rawKey, value] of Object.entries(storage)) {
|
|
166
|
+
// Strip the 'local:' or 'session:' prefix for key matching
|
|
167
|
+
const key = rawKey.replace(/^(local|session):/, '');
|
|
168
|
+
if (key.toLowerCase().includes('refreshtoken')) {
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(value);
|
|
171
|
+
if (parsed.secret) {
|
|
172
|
+
refreshToken = parsed.secret;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Not JSON — skip
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const expiresAt = bestPayload?.exp
|
|
183
|
+
? new Date(bestPayload.exp * 1000).toISOString()
|
|
184
|
+
: undefined;
|
|
185
|
+
|
|
186
|
+
const credential: BearerCredential = {
|
|
187
|
+
type: 'bearer',
|
|
188
|
+
accessToken: bestToken,
|
|
189
|
+
refreshToken,
|
|
190
|
+
expiresAt,
|
|
191
|
+
scopes: bestPayload?.scp
|
|
192
|
+
? String(bestPayload.scp).split(' ')
|
|
193
|
+
: undefined,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return ok(credential);
|
|
197
|
+
} catch (e: unknown) {
|
|
198
|
+
return err(new BrowserError(`Token extraction failed: ${(e as Error).message}`));
|
|
199
|
+
}
|
|
200
|
+
}
|