tr-plugin-auth 0.0.0-stage → 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/README.md +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +266 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1687 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated browser sessions for TR.
|
|
3
|
+
*
|
|
4
|
+
* TR's existing Playwright use (source-adapters.playwrightScrape) is stateless
|
|
5
|
+
* scraping: `chromium.launch()` with a throwaway context. Provider consoles
|
|
6
|
+
* require a logged-in session, so rotation needs a *persistent* profile that
|
|
7
|
+
* survives across runs — you log in once per provider, and the daemon can drive
|
|
8
|
+
* rotations later without re-auth.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: the profile directory holds live session cookies for Stripe,
|
|
11
|
+
* GitHub, etc. It is at least as sensitive as secrets.enc and is created 0700.
|
|
12
|
+
* Never copy it, never sync it, never log its contents.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const PROFILE_DIRNAME = 'browser-profile';
|
|
19
|
+
|
|
20
|
+
const PROFILE_README = `# TR browser profile — DO NOT SYNC, DO NOT COPY
|
|
21
|
+
|
|
22
|
+
This directory holds live authenticated sessions (Stripe, GitHub, cloud consoles)
|
|
23
|
+
used by \`total-recall secret rotate-auto\`.
|
|
24
|
+
|
|
25
|
+
Treat it as equivalent to secrets.enc:
|
|
26
|
+
- never commit it
|
|
27
|
+
- never include it in backups that leave this machine
|
|
28
|
+
- delete it with \`total-recall secret browser-logout\` when no longer needed
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load chromium if playwright is installed. Mirrors source-adapters.getPlaywright
|
|
33
|
+
* so browser support stays an optional dependency.
|
|
34
|
+
* @returns {Promise<any|null>}
|
|
35
|
+
*/
|
|
36
|
+
export async function getChromium() {
|
|
37
|
+
try {
|
|
38
|
+
const pw = await import('playwright');
|
|
39
|
+
return pw.chromium || pw.default?.chromium || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} brainDir
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveProfileDir(brainDir) {
|
|
50
|
+
return path.join(brainDir, PROFILE_DIRNAME);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create the profile dir with restrictive permissions if absent.
|
|
55
|
+
* @param {string} brainDir
|
|
56
|
+
*/
|
|
57
|
+
export function ensureProfileDir(brainDir) {
|
|
58
|
+
const dir = resolveProfileDir(brainDir);
|
|
59
|
+
if (!fs.existsSync(dir)) {
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'README.md'), PROFILE_README, { mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
fs.chmodSync(dir, 0o700);
|
|
65
|
+
} catch {
|
|
66
|
+
/* best effort on exotic filesystems */
|
|
67
|
+
}
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Launch a persistent, authenticated browser context.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} brainDir
|
|
75
|
+
* @param {{ headless?: boolean, timeoutMs?: number }} [opts]
|
|
76
|
+
* @returns {Promise<{ ok: boolean, context?: any, error?: string, profileDir?: string }>}
|
|
77
|
+
*/
|
|
78
|
+
export async function launchRotationContext(brainDir, opts = {}) {
|
|
79
|
+
const chromium = await getChromium();
|
|
80
|
+
if (!chromium) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error:
|
|
84
|
+
'playwright is not installed. Run: npm install playwright && npx playwright install chromium',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const profileDir = ensureProfileDir(brainDir);
|
|
89
|
+
// Headed by default: provider logins routinely require human 2FA, and a
|
|
90
|
+
// headless first-run would simply hang on an invisible challenge.
|
|
91
|
+
const headless = opts.headless === true;
|
|
92
|
+
|
|
93
|
+
const base = {
|
|
94
|
+
headless,
|
|
95
|
+
viewport: { width: 1440, height: 900 },
|
|
96
|
+
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Playwright's bundled Chromium is not available on every host — it has
|
|
100
|
+
// dropped support for older macOS versions, and the pinned build is often
|
|
101
|
+
// simply not downloaded. A rotation that dies here strands the operator with
|
|
102
|
+
// live, burned credentials, so fall back to any locally installed Chrome or
|
|
103
|
+
// Edge before giving up.
|
|
104
|
+
const attempts = [
|
|
105
|
+
{ label: 'bundled chromium', options: base },
|
|
106
|
+
{ label: 'system chrome', options: { ...base, channel: 'chrome' } },
|
|
107
|
+
{ label: 'system edge', options: { ...base, channel: 'msedge' } },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const failures = [];
|
|
111
|
+
for (const attempt of attempts) {
|
|
112
|
+
try {
|
|
113
|
+
const context = await chromium.launchPersistentContext(profileDir, attempt.options);
|
|
114
|
+
context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
|
|
115
|
+
return { ok: true, context, profileDir, browser: attempt.label };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error:
|
|
124
|
+
`failed to launch a browser profile. Tried ${attempts.length} browsers —\n ` +
|
|
125
|
+
failures.join('\n ') +
|
|
126
|
+
'\nInstall one with: npx playwright install chromium (or install Google Chrome).',
|
|
127
|
+
profileDir,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Navigate to a console and report whether the session is authenticated.
|
|
133
|
+
*
|
|
134
|
+
* Recipes describe authentication by what is visible, not by URL alone, because
|
|
135
|
+
* most consoles redirect to a login page rather than returning an error status.
|
|
136
|
+
*
|
|
137
|
+
* @param {any} context
|
|
138
|
+
* @param {{ console_url: string, signed_in?: string, signed_out?: string }} recipe
|
|
139
|
+
* @returns {Promise<{ page: any, authenticated: boolean, url: string }>}
|
|
140
|
+
*/
|
|
141
|
+
export async function openConsole(context, recipe) {
|
|
142
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
143
|
+
await page.goto(recipe.console_url, { waitUntil: 'domcontentloaded' });
|
|
144
|
+
// Consoles are SPA-heavy; give client-side auth redirects a beat to settle.
|
|
145
|
+
await page.waitForTimeout(1500);
|
|
146
|
+
|
|
147
|
+
const authenticated = await isAuthenticated(page, recipe);
|
|
148
|
+
return { page, authenticated, url: page.url() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {any} page
|
|
153
|
+
* @param {{ signed_in?: string, signed_out?: string }} recipe
|
|
154
|
+
* @returns {Promise<boolean>}
|
|
155
|
+
*/
|
|
156
|
+
export async function isAuthenticated(page, recipe, opts = {}) {
|
|
157
|
+
// A visible signed-out marker (login form, "Sign in" CTA) is decisive.
|
|
158
|
+
if (recipe.signed_out) {
|
|
159
|
+
const out = await page.locator(recipe.signed_out).first().isVisible().catch(() => false);
|
|
160
|
+
if (out) return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (recipe.signed_in) {
|
|
164
|
+
// Consoles are SPA-heavy: the positive marker frequently renders a second
|
|
165
|
+
// or two after DOMContentLoaded. Give it a real wait instead of a single
|
|
166
|
+
// instantaneous probe.
|
|
167
|
+
const appeared = await page
|
|
168
|
+
.locator(recipe.signed_in)
|
|
169
|
+
.first()
|
|
170
|
+
.waitFor({ state: 'visible', timeout: opts.markerTimeoutMs ?? 8000 })
|
|
171
|
+
.then(() => true)
|
|
172
|
+
.catch(() => false);
|
|
173
|
+
if (appeared) return true;
|
|
174
|
+
|
|
175
|
+
// The positive marker never showed and no login form matched. Selectors go
|
|
176
|
+
// stale constantly, so fall back to the one signal every console agrees on:
|
|
177
|
+
// an unauthenticated request gets redirected to a login URL.
|
|
178
|
+
return !looksLikeLoginUrl(page.url());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return !looksLikeLoginUrl(page.url());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Provider-agnostic sign-in detection. Consoles redirect anonymous visitors to
|
|
186
|
+
* a login route rather than returning an error status, so the landed URL is a
|
|
187
|
+
* far more durable signal than any per-provider CSS selector.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} url
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
export function looksLikeLoginUrl(url) {
|
|
193
|
+
return /\/(login|log-in|sign[_-]?in|signin|auth\/login|sessions\/new|u\/login)(\/|\?|#|$)/i.test(
|
|
194
|
+
String(url || ''),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Wait for a human to complete login/2FA in the headed window.
|
|
200
|
+
*
|
|
201
|
+
* @param {any} page
|
|
202
|
+
* @param {object} recipe
|
|
203
|
+
* @param {{ timeoutMs?: number, pollMs?: number, onWait?: (s:number)=>void }} [opts]
|
|
204
|
+
* @returns {Promise<boolean>}
|
|
205
|
+
*/
|
|
206
|
+
export async function waitForLogin(page, recipe, opts = {}) {
|
|
207
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
208
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
209
|
+
const started = Date.now();
|
|
210
|
+
|
|
211
|
+
while (Date.now() - started < timeoutMs) {
|
|
212
|
+
if (await isAuthenticated(page, recipe)) return true;
|
|
213
|
+
opts.onWait?.(Math.round((Date.now() - started) / 1000));
|
|
214
|
+
await page.waitForTimeout(pollMs);
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Close a context without throwing.
|
|
221
|
+
* @param {any} context
|
|
222
|
+
*/
|
|
223
|
+
export async function closeContext(context) {
|
|
224
|
+
try {
|
|
225
|
+
await context?.close();
|
|
226
|
+
} catch {
|
|
227
|
+
/* already closed */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Delete the persistent profile (logout everywhere).
|
|
233
|
+
* @param {string} brainDir
|
|
234
|
+
*/
|
|
235
|
+
export function clearProfile(brainDir) {
|
|
236
|
+
const dir = resolveProfileDir(brainDir);
|
|
237
|
+
if (!fs.existsSync(dir)) return { removed: false, path: dir };
|
|
238
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
return { removed: true, path: dir };
|
|
240
|
+
}
|