@docsxai/engine 0.2.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 +202 -0
- package/README.md +130 -0
- package/dist/auth/api-login.d.ts +69 -0
- package/dist/auth/api-login.js +95 -0
- package/dist/auth/browser-session.d.ts +28 -0
- package/dist/auth/browser-session.js +43 -0
- package/dist/auth/cookie-jar.d.ts +58 -0
- package/dist/auth/cookie-jar.js +212 -0
- package/dist/auth/email-otp.d.ts +210 -0
- package/dist/auth/email-otp.js +166 -0
- package/dist/auth/http-basic.d.ts +5 -0
- package/dist/auth/http-basic.js +17 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +137 -0
- package/dist/auth/jwt-injection.d.ts +153 -0
- package/dist/auth/jwt-injection.js +136 -0
- package/dist/auth/manual-capture.d.ts +35 -0
- package/dist/auth/manual-capture.js +30 -0
- package/dist/auth/mtls.d.ts +15 -0
- package/dist/auth/mtls.js +53 -0
- package/dist/auth/pat-header.d.ts +19 -0
- package/dist/auth/pat-header.js +34 -0
- package/dist/auth/storage-state-cache.d.ts +38 -0
- package/dist/auth/storage-state-cache.js +143 -0
- package/dist/auth/test-backdoor.d.ts +25 -0
- package/dist/auth/test-backdoor.js +51 -0
- package/dist/auth/totp.d.ts +39 -0
- package/dist/auth/totp.js +108 -0
- package/dist/auth/types.d.ts +86 -0
- package/dist/auth/types.js +57 -0
- package/dist/auth/ui-form.d.ts +204 -0
- package/dist/auth/ui-form.js +153 -0
- package/dist/auth/webauthn.d.ts +88 -0
- package/dist/auth/webauthn.js +67 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +3 -0
- package/dist/backend-client-contracts.d.ts +88 -0
- package/dist/backend-client-contracts.js +19 -0
- package/dist/backend-client-oauth-login.d.ts +7 -0
- package/dist/backend-client-oauth-login.js +90 -0
- package/dist/backend-client-state-cache.d.ts +73 -0
- package/dist/backend-client-state-cache.js +185 -0
- package/dist/backend-client-token.d.ts +18 -0
- package/dist/backend-client-token.js +94 -0
- package/dist/backend-client-transport.d.ts +66 -0
- package/dist/backend-client-transport.js +181 -0
- package/dist/backend-client.d.ts +5 -0
- package/dist/backend-client.js +18 -0
- package/dist/calibrate.d.ts +31 -0
- package/dist/calibrate.js +68 -0
- package/dist/cli-commands-authoring.d.ts +5 -0
- package/dist/cli-commands-authoring.js +403 -0
- package/dist/cli-commands-backend.d.ts +5 -0
- package/dist/cli-commands-backend.js +211 -0
- package/dist/cli-commands-docpack.d.ts +5 -0
- package/dist/cli-commands-docpack.js +280 -0
- package/dist/cli-commands-session.d.ts +4 -0
- package/dist/cli-commands-session.js +398 -0
- package/dist/cli-shared.d.ts +5 -0
- package/dist/cli-shared.js +45 -0
- package/dist/cli-usage.d.ts +1 -0
- package/dist/cli-usage.js +137 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +77 -0
- package/dist/diagnose.d.ts +50 -0
- package/dist/diagnose.js +168 -0
- package/dist/diff-compute.d.ts +13 -0
- package/dist/diff-compute.js +378 -0
- package/dist/diff-report.d.ts +7 -0
- package/dist/diff-report.js +125 -0
- package/dist/diff-types.d.ts +125 -0
- package/dist/diff-types.js +15 -0
- package/dist/diff.d.ts +3 -0
- package/dist/diff.js +16 -0
- package/dist/doc-pack-io.d.ts +30 -0
- package/dist/doc-pack-io.js +182 -0
- package/dist/doc-pack.d.ts +1814 -0
- package/dist/doc-pack.js +328 -0
- package/dist/doctor-checks-plugins.d.ts +2 -0
- package/dist/doctor-checks-plugins.js +136 -0
- package/dist/doctor-checks.d.ts +56 -0
- package/dist/doctor-checks.js +367 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +62 -0
- package/dist/export/adf.d.ts +57 -0
- package/dist/export/adf.js +323 -0
- package/dist/export/playwright-test.d.ts +26 -0
- package/dist/export/playwright-test.js +221 -0
- package/dist/flow-file.d.ts +21 -0
- package/dist/flow-file.js +180 -0
- package/dist/flow-lint.d.ts +24 -0
- package/dist/flow-lint.js +203 -0
- package/dist/flow-runtime.d.ts +113 -0
- package/dist/flow-runtime.js +273 -0
- package/dist/flow-tree.d.ts +19 -0
- package/dist/flow-tree.js +104 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +31 -0
- package/dist/playwright-driver.d.ts +105 -0
- package/dist/playwright-driver.js +363 -0
- package/dist/playwright-instrumented-browser.d.ts +51 -0
- package/dist/playwright-instrumented-browser.js +189 -0
- package/dist/plugins/load.d.ts +22 -0
- package/dist/plugins/load.js +99 -0
- package/dist/plugins/lock.d.ts +40 -0
- package/dist/plugins/lock.js +122 -0
- package/dist/plugins/manifest.d.ts +70 -0
- package/dist/plugins/manifest.js +115 -0
- package/dist/plugins/plan.d.ts +51 -0
- package/dist/plugins/plan.js +279 -0
- package/dist/plugins/registry.d.ts +59 -0
- package/dist/plugins/registry.js +71 -0
- package/dist/plugins/runtime.d.ts +7 -0
- package/dist/plugins/runtime.js +27 -0
- package/dist/plugins/types.d.ts +58 -0
- package/dist/plugins/types.js +4 -0
- package/dist/plugins-cli.d.ts +1 -0
- package/dist/plugins-cli.js +191 -0
- package/dist/redact.d.ts +16 -0
- package/dist/redact.js +72 -0
- package/dist/style.d.ts +46 -0
- package/dist/style.js +151 -0
- package/dist/viewer-bin.d.ts +20 -0
- package/dist/viewer-bin.js +97 -0
- package/dist/workspace.d.ts +60 -0
- package/dist/workspace.js +172 -0
- package/dist/zip.d.ts +17 -0
- package/dist/zip.js +113 -0
- package/package.json +64 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Local storageState cache (`.auth/<role>.json`) — used by expensive strategies (manual-capture, ui-form, ...).
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { BackendStateCache, resolveBackendToken } from "../backend-client.js";
|
|
5
|
+
import { loadWorkspaceConfig, resolveWorkspacePath, resolveWorkspacePathReal, } from "../workspace.js";
|
|
6
|
+
import { AuthStrategyConfigError, cookieExpiryByName, } from "./types.js";
|
|
7
|
+
const CachedStateSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
storageState: z.object({ cookies: z.array(z.any()), origins: z.array(z.any()) }),
|
|
10
|
+
writtenAt: z.number(),
|
|
11
|
+
expiresAt: z.number(),
|
|
12
|
+
})
|
|
13
|
+
.passthrough();
|
|
14
|
+
function ttlToMs(ttl) {
|
|
15
|
+
if (ttl === "session")
|
|
16
|
+
return "session";
|
|
17
|
+
if (typeof ttl === "number")
|
|
18
|
+
return ttl;
|
|
19
|
+
const m = /^(\d+)(ms|s|m|h)$/.exec(ttl);
|
|
20
|
+
const n = Number(m[1]);
|
|
21
|
+
switch (m[2]) {
|
|
22
|
+
case "ms":
|
|
23
|
+
return n;
|
|
24
|
+
case "s":
|
|
25
|
+
return n * 1000;
|
|
26
|
+
case "m":
|
|
27
|
+
return n * 60_000;
|
|
28
|
+
case "h":
|
|
29
|
+
return n * 3_600_000;
|
|
30
|
+
default:
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class LocalStorageStateCache {
|
|
35
|
+
dir;
|
|
36
|
+
/** @param dir the `.auth/` directory (relative paths resolved against cwd). */
|
|
37
|
+
constructor(dir = ".auth") {
|
|
38
|
+
this.dir = dir;
|
|
39
|
+
}
|
|
40
|
+
fileName(role) {
|
|
41
|
+
// role names are simple identifiers in practice; still, keep the filename safe.
|
|
42
|
+
const safe = role.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
43
|
+
return `${safe}.json`;
|
|
44
|
+
}
|
|
45
|
+
file(role) {
|
|
46
|
+
return resolveWorkspacePath(this.dir, this.fileName(role));
|
|
47
|
+
}
|
|
48
|
+
/** Return the cached state for a role if present and not past its expiry; otherwise `null`. */
|
|
49
|
+
async load(role, now = Date.now()) {
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
text = await fs.readFile(this.file(role), "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = CachedStateSchema.parse(JSON.parse(text));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null; // corrupt cache → treat as miss
|
|
63
|
+
}
|
|
64
|
+
if (parsed.expiresAt <= now)
|
|
65
|
+
return null;
|
|
66
|
+
return parsed.storageState;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Persist a captured session and compute its expiry, in priority order:
|
|
70
|
+
* 1. **The app's auth cookie** — if `auth_cookie` (descriptor or override) names a cookie that's in the
|
|
71
|
+
* captured jar with a real (non-session) expiry, that expiry *is* the bound. This is the right answer;
|
|
72
|
+
* the host agent identifies which cookie it is (it's on the app's domain, long-lived — not an ephemeral
|
|
73
|
+
* IdP scratch cookie). Why not just `min(cookie.expires)`? An interactive SSO login drops scratch cookies
|
|
74
|
+
* that expire seconds out, so the min ≈ now and the session would be born expired.
|
|
75
|
+
* 2. **`ttl`** — a duration (`1h`, `30m`, ms) → `now + ttl`. The fallback when no `auth_cookie` is set/found.
|
|
76
|
+
* 3. **`session` / default** — the strategy's reported `expiresAt` if plausibly in the future, else +1h.
|
|
77
|
+
* Returns the computed `expiresAt` and a human-readable `source`.
|
|
78
|
+
*/
|
|
79
|
+
async save(role, result, roleAuth, now = Date.now(), opts = {}) {
|
|
80
|
+
const authCookieName = opts.authCookie ?? roleAuth.cache.auth_cookie;
|
|
81
|
+
const fromCookie = authCookieName
|
|
82
|
+
? cookieExpiryByName(result.storageState, authCookieName)
|
|
83
|
+
: undefined;
|
|
84
|
+
const ttlMs = ttlToMs(roleAuth.cache.ttl);
|
|
85
|
+
let expiresAt;
|
|
86
|
+
let source;
|
|
87
|
+
if (fromCookie !== undefined) {
|
|
88
|
+
expiresAt = fromCookie;
|
|
89
|
+
source = `auth-cookie "${authCookieName}"`;
|
|
90
|
+
}
|
|
91
|
+
else if (authCookieName) {
|
|
92
|
+
expiresAt = typeof ttlMs === "number" ? now + ttlMs : now + 3_600_000;
|
|
93
|
+
source = `ttl (fallback — auth-cookie "${authCookieName}" not in the jar or has no expiry)`;
|
|
94
|
+
}
|
|
95
|
+
else if (typeof ttlMs === "number") {
|
|
96
|
+
expiresAt = now + ttlMs;
|
|
97
|
+
source = "ttl";
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const reported = result.expiresAt && result.expiresAt > now + 60_000 ? result.expiresAt : undefined;
|
|
101
|
+
expiresAt = reported ?? now + 3_600_000;
|
|
102
|
+
source = reported ? "strategy-reported expiresAt" : "1h default";
|
|
103
|
+
}
|
|
104
|
+
if (expiresAt <= now) {
|
|
105
|
+
throw new AuthStrategyConfigError(`computed cache expiry (${new Date(expiresAt).toISOString()}, from ${source}) is not in the future — refusing to cache a dead session`);
|
|
106
|
+
}
|
|
107
|
+
const entry = { storageState: result.storageState, writtenAt: now, expiresAt };
|
|
108
|
+
await fs.mkdir(resolveWorkspacePath(this.dir), { recursive: true });
|
|
109
|
+
// Role names are operator-influenced — resolve with the symlink-aware variant before writing.
|
|
110
|
+
const target = await resolveWorkspacePathReal(this.dir, this.fileName(role));
|
|
111
|
+
await fs.writeFile(target, JSON.stringify(entry, null, 2) + "\n", "utf8");
|
|
112
|
+
return { expiresAt, source };
|
|
113
|
+
}
|
|
114
|
+
async clear(role) {
|
|
115
|
+
await fs.rm(this.file(role), { force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pick the state cache for a role. `store: local` (the default) caches under `<workspace>/.auth/`.
|
|
120
|
+
* `store: backend` relays AES-256-GCM envelopes through the backend (encrypted client-side; the
|
|
121
|
+
* backend never sees plaintext) — it needs a workspace that has been pushed (`backend_url` +
|
|
122
|
+
* `backend_workspace_id` in `.docsxai.json`) and `DOCSX_CACHE_KEY`.
|
|
123
|
+
*/
|
|
124
|
+
export async function resolveStateCache(roleAuth, workspaceDir) {
|
|
125
|
+
if (roleAuth.cache.store === "backend") {
|
|
126
|
+
const cfg = await loadWorkspaceConfig(workspaceDir);
|
|
127
|
+
if (!cfg?.backend_url || !cfg.backend_workspace_id) {
|
|
128
|
+
throw new AuthStrategyConfigError("cache.store: backend needs a backend-bound workspace — run `docsxai push` first (backend_url + backend_workspace_id in .docsxai.json)");
|
|
129
|
+
}
|
|
130
|
+
const cacheKey = process.env.DOCSX_CACHE_KEY;
|
|
131
|
+
if (!cacheKey) {
|
|
132
|
+
throw new AuthStrategyConfigError("cache.store: backend requires DOCSX_CACHE_KEY (base64-encoded 32-byte key)");
|
|
133
|
+
}
|
|
134
|
+
const token = await resolveBackendToken({ baseUrl: cfg.backend_url, workspaceDir });
|
|
135
|
+
return new BackendStateCache({
|
|
136
|
+
baseUrl: cfg.backend_url,
|
|
137
|
+
token,
|
|
138
|
+
workspaceId: cfg.backend_workspace_id,
|
|
139
|
+
cacheKey,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return new LocalStorageStateCache(resolveWorkspacePath(workspaceDir, ".auth"));
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type AuthContext, type AuthResult, type AuthStrategy } from "./types.js";
|
|
3
|
+
export declare const TestBackdoorOptions: z.ZodObject<{
|
|
4
|
+
/** Backdoor endpoint; resolved against the target's base URL when relative. */
|
|
5
|
+
url: z.ZodString;
|
|
6
|
+
/** User to impersonate; sent verbatim in the request body. */
|
|
7
|
+
user_id: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
|
|
8
|
+
/** Cookie that proves the backdoor worked. Optional; any Set-Cookie + non-4xx passes without it. */
|
|
9
|
+
success_cookie: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, "strict", z.ZodTypeAny, {
|
|
11
|
+
url: string;
|
|
12
|
+
user_id?: string | number | undefined;
|
|
13
|
+
success_cookie?: string | undefined;
|
|
14
|
+
}, {
|
|
15
|
+
url: string;
|
|
16
|
+
user_id?: string | number | undefined;
|
|
17
|
+
success_cookie?: string | undefined;
|
|
18
|
+
}>;
|
|
19
|
+
export type TestBackdoorOptions = z.infer<typeof TestBackdoorOptions>;
|
|
20
|
+
export declare class TestBackdoorStrategy implements AuthStrategy {
|
|
21
|
+
private readonly fetchImpl;
|
|
22
|
+
readonly name: "test-backdoor";
|
|
23
|
+
constructor(fetchImpl?: typeof fetch);
|
|
24
|
+
authenticate(ctx: AuthContext): Promise<AuthResult>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// `test-backdoor` — POST a shared secret to a test-only login endpoint the target app exposes in
|
|
2
|
+
// non-production builds, and keep the session cookies it sets. The unattended-execution answer
|
|
3
|
+
// when the app team can ship a backdoor route; the secret lives in env, never in the descriptor.
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fetchCollectingCookies, jarAuthExpiry } from "./cookie-jar.js";
|
|
6
|
+
import { AuthStrategyConfigError, maskSecret, parseStrategyOptions, } from "./types.js";
|
|
7
|
+
export const TestBackdoorOptions = z
|
|
8
|
+
.object({
|
|
9
|
+
/** Backdoor endpoint; resolved against the target's base URL when relative. */
|
|
10
|
+
url: z.string().min(1),
|
|
11
|
+
/** User to impersonate; sent verbatim in the request body. */
|
|
12
|
+
user_id: z.union([z.string(), z.number()]).optional(),
|
|
13
|
+
/** Cookie that proves the backdoor worked. Optional; any Set-Cookie + non-4xx passes without it. */
|
|
14
|
+
success_cookie: z.string().min(1).optional(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
export class TestBackdoorStrategy {
|
|
18
|
+
fetchImpl;
|
|
19
|
+
name = "test-backdoor";
|
|
20
|
+
constructor(fetchImpl = fetch) {
|
|
21
|
+
this.fetchImpl = fetchImpl;
|
|
22
|
+
}
|
|
23
|
+
async authenticate(ctx) {
|
|
24
|
+
const opts = parseStrategyOptions(this.name, TestBackdoorOptions, ctx.options);
|
|
25
|
+
const secret = ctx.creds.secret;
|
|
26
|
+
if (!secret) {
|
|
27
|
+
throw new AuthStrategyConfigError(`test-backdoor: creds_env must map "secret" to the env var holding the backdoor secret (secret: ${maskSecret(secret)})`);
|
|
28
|
+
}
|
|
29
|
+
const url = new URL(opts.url, ctx.baseURL);
|
|
30
|
+
const result = await fetchCollectingCookies(url, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
secret,
|
|
35
|
+
...(opts.user_id !== undefined ? { user_id: opts.user_id } : {}),
|
|
36
|
+
}),
|
|
37
|
+
}, { fetchImpl: this.fetchImpl });
|
|
38
|
+
if (result.status >= 400) {
|
|
39
|
+
throw new AuthStrategyConfigError(`test-backdoor: ${url.href} answered ${result.status} (secret: ${maskSecret(secret)} — value not shown)`);
|
|
40
|
+
}
|
|
41
|
+
if (opts.success_cookie && !result.jar.has(opts.success_cookie)) {
|
|
42
|
+
throw new AuthStrategyConfigError(`test-backdoor: expected cookie "${opts.success_cookie}" was not set by ${url.href}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.jar.cookies().length === 0) {
|
|
45
|
+
throw new AuthStrategyConfigError(`test-backdoor: ${url.href} answered ${result.status} but set no cookies — nothing to capture`);
|
|
46
|
+
}
|
|
47
|
+
const storageState = result.jar.toStorageState();
|
|
48
|
+
const expiresAt = jarAuthExpiry(storageState, opts.success_cookie);
|
|
49
|
+
return { storageState, ...(expiresAt !== undefined ? { expiresAt } : {}) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type AuthContext, type AuthResult, type AuthStrategy } from "./types.js";
|
|
2
|
+
/** Decode an RFC 4648 base32 string (case-insensitive; `=` padding and inner whitespace tolerated). */
|
|
3
|
+
export declare function base32Decode(encoded: string): Buffer;
|
|
4
|
+
/** Encode bytes as RFC 4648 base32 (no padding). Test/fixture aid; authenticator secrets arrive base32. */
|
|
5
|
+
export declare function base32Encode(data: Uint8Array): string;
|
|
6
|
+
export type TotpAlgorithm = "sha1" | "sha256";
|
|
7
|
+
export interface TotpOptions {
|
|
8
|
+
/** Wall-clock instant the code is for. Epoch ms (or a `Date`). Default: now. */
|
|
9
|
+
at?: number | Date;
|
|
10
|
+
/** Code length: 6 (default) or 8. */
|
|
11
|
+
digits?: 6 | 8;
|
|
12
|
+
/** Time-step in seconds. Default 30. */
|
|
13
|
+
period?: number;
|
|
14
|
+
/** HMAC algorithm. Default `sha1` (what authenticator apps implement). */
|
|
15
|
+
algorithm?: TotpAlgorithm;
|
|
16
|
+
}
|
|
17
|
+
/** RFC 4226 HOTP: HMAC the 8-byte big-endian counter, dynamic-truncate, mod 10^digits. */
|
|
18
|
+
export declare function generateHotp(key: Uint8Array, counter: number | bigint, digits?: 6 | 8, algorithm?: TotpAlgorithm): string;
|
|
19
|
+
/** RFC 6238 TOTP for `secret` (base32 string, or raw key bytes). */
|
|
20
|
+
export declare function generateTotp(secret: string | Uint8Array, opts?: TotpOptions): string;
|
|
21
|
+
/**
|
|
22
|
+
* Codes for the time-steps within ±`window` of `at` (clock-drift tolerance), centre first.
|
|
23
|
+
* `window: 1` (the common server allowance) yields the current, previous, and next codes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function totpCodesAround(secret: string | Uint8Array, opts?: TotpOptions & {
|
|
26
|
+
window?: number;
|
|
27
|
+
}): string[];
|
|
28
|
+
/** True when `code` matches any time-step within ±`window` (default 1) of `at`. */
|
|
29
|
+
export declare function verifyTotp(code: string, secret: string | Uint8Array, opts?: TotpOptions & {
|
|
30
|
+
window?: number;
|
|
31
|
+
}): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The standalone `totp` catalogue entry. A TOTP is one field of an interactive login, not a whole
|
|
34
|
+
* scheme — authenticating with it alone is a config error that points at the composition.
|
|
35
|
+
*/
|
|
36
|
+
export declare class TotpStrategy implements AuthStrategy {
|
|
37
|
+
readonly name: "totp";
|
|
38
|
+
authenticate(_ctx: AuthContext): Promise<AuthResult>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Dep-free RFC 6238 TOTP (over RFC 4226 HOTP) with `node:crypto`.
|
|
2
|
+
//
|
|
3
|
+
// `totp` is not a standalone login scheme — a one-time code is one *field* of an interactive
|
|
4
|
+
// login — so the catalogue entry composes `ui-form` (set `options.totp` there). The primitives
|
|
5
|
+
// are exported for that hook, for fixture servers, and for future CLI/MCP surfacing.
|
|
6
|
+
import { createHmac } from "node:crypto";
|
|
7
|
+
import { AuthStrategyConfigError, } from "./types.js";
|
|
8
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
9
|
+
/** Decode an RFC 4648 base32 string (case-insensitive; `=` padding and inner whitespace tolerated). */
|
|
10
|
+
export function base32Decode(encoded) {
|
|
11
|
+
const clean = encoded.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
|
|
12
|
+
if (clean.length === 0)
|
|
13
|
+
return Buffer.alloc(0);
|
|
14
|
+
let bits = 0;
|
|
15
|
+
let acc = 0;
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const ch of clean) {
|
|
18
|
+
const idx = BASE32_ALPHABET.indexOf(ch);
|
|
19
|
+
if (idx === -1) {
|
|
20
|
+
throw new AuthStrategyConfigError(`base32: invalid character "${ch}"`);
|
|
21
|
+
}
|
|
22
|
+
acc = (acc << 5) | idx;
|
|
23
|
+
bits += 5;
|
|
24
|
+
if (bits >= 8) {
|
|
25
|
+
bits -= 8;
|
|
26
|
+
out.push((acc >> bits) & 0xff);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Buffer.from(out);
|
|
30
|
+
}
|
|
31
|
+
/** Encode bytes as RFC 4648 base32 (no padding). Test/fixture aid; authenticator secrets arrive base32. */
|
|
32
|
+
export function base32Encode(data) {
|
|
33
|
+
let bits = 0;
|
|
34
|
+
let acc = 0;
|
|
35
|
+
let out = "";
|
|
36
|
+
for (const byte of data) {
|
|
37
|
+
acc = (acc << 8) | byte;
|
|
38
|
+
bits += 8;
|
|
39
|
+
while (bits >= 5) {
|
|
40
|
+
bits -= 5;
|
|
41
|
+
out += BASE32_ALPHABET[(acc >> bits) & 31];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (bits > 0)
|
|
45
|
+
out += BASE32_ALPHABET[(acc << (5 - bits)) & 31];
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** RFC 4226 HOTP: HMAC the 8-byte big-endian counter, dynamic-truncate, mod 10^digits. */
|
|
49
|
+
export function generateHotp(key, counter, digits = 6, algorithm = "sha1") {
|
|
50
|
+
const counterBuf = Buffer.alloc(8);
|
|
51
|
+
counterBuf.writeBigUInt64BE(BigInt(counter));
|
|
52
|
+
const mac = createHmac(algorithm, key).update(counterBuf).digest();
|
|
53
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
54
|
+
const binCode = ((mac[offset] & 0x7f) << 24) |
|
|
55
|
+
(mac[offset + 1] << 16) |
|
|
56
|
+
(mac[offset + 2] << 8) |
|
|
57
|
+
mac[offset + 3];
|
|
58
|
+
return String(binCode % 10 ** digits).padStart(digits, "0");
|
|
59
|
+
}
|
|
60
|
+
function keyOf(secret) {
|
|
61
|
+
return typeof secret === "string" ? base32Decode(secret) : Buffer.from(secret);
|
|
62
|
+
}
|
|
63
|
+
function counterAt(at, period) {
|
|
64
|
+
const ms = at instanceof Date ? at.getTime() : (at ?? Date.now());
|
|
65
|
+
return BigInt(Math.floor(ms / 1000 / period));
|
|
66
|
+
}
|
|
67
|
+
/** RFC 6238 TOTP for `secret` (base32 string, or raw key bytes). */
|
|
68
|
+
export function generateTotp(secret, opts = {}) {
|
|
69
|
+
const period = opts.period ?? 30;
|
|
70
|
+
return generateHotp(keyOf(secret), counterAt(opts.at, period), opts.digits ?? 6, opts.algorithm ?? "sha1");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Codes for the time-steps within ±`window` of `at` (clock-drift tolerance), centre first.
|
|
74
|
+
* `window: 1` (the common server allowance) yields the current, previous, and next codes.
|
|
75
|
+
*/
|
|
76
|
+
export function totpCodesAround(secret, opts = {}) {
|
|
77
|
+
const period = opts.period ?? 30;
|
|
78
|
+
const window = opts.window ?? 1;
|
|
79
|
+
const key = keyOf(secret);
|
|
80
|
+
const centre = counterAt(opts.at, period);
|
|
81
|
+
const codes = [];
|
|
82
|
+
for (let drift = 0; drift <= window; drift++) {
|
|
83
|
+
for (const counter of drift === 0
|
|
84
|
+
? [centre]
|
|
85
|
+
: [centre - BigInt(drift), centre + BigInt(drift)]) {
|
|
86
|
+
if (counter < 0n)
|
|
87
|
+
continue;
|
|
88
|
+
codes.push(generateHotp(key, counter, opts.digits ?? 6, opts.algorithm ?? "sha1"));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return codes;
|
|
92
|
+
}
|
|
93
|
+
/** True when `code` matches any time-step within ±`window` (default 1) of `at`. */
|
|
94
|
+
export function verifyTotp(code, secret, opts = {}) {
|
|
95
|
+
return totpCodesAround(secret, opts).includes(code);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The standalone `totp` catalogue entry. A TOTP is one field of an interactive login, not a whole
|
|
99
|
+
* scheme — authenticating with it alone is a config error that points at the composition.
|
|
100
|
+
*/
|
|
101
|
+
export class TotpStrategy {
|
|
102
|
+
name = "totp";
|
|
103
|
+
authenticate(_ctx) {
|
|
104
|
+
throw new AuthStrategyConfigError("strategy `totp` is not a standalone login — use strategy `ui-form` with " +
|
|
105
|
+
"options.totp: { secret_env: <ENV-VAR NAME>, otp_selector: <css>, submit_selector?: <css> }; " +
|
|
106
|
+
"the TOTP primitives (generateTotp / verifyTotp) are exported for direct use");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Structural shape of Playwright's `BrowserContext.storageState()` output. We don't import Playwright here. */
|
|
3
|
+
export interface StorageState {
|
|
4
|
+
cookies: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
domain: string;
|
|
8
|
+
path: string;
|
|
9
|
+
expires: number;
|
|
10
|
+
httpOnly: boolean;
|
|
11
|
+
secure: boolean;
|
|
12
|
+
sameSite: "Strict" | "Lax" | "None";
|
|
13
|
+
}>;
|
|
14
|
+
origins: Array<{
|
|
15
|
+
origin: string;
|
|
16
|
+
localStorage: Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}>;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/** One captured cookie in a {@link StorageState} jar. */
|
|
23
|
+
export type StorageStateCookie = StorageState["cookies"][number];
|
|
24
|
+
/** An empty jar — for strategies whose "auth" is connection-level, not storage-level. */
|
|
25
|
+
export declare function emptyStorageState(): StorageState;
|
|
26
|
+
/**
|
|
27
|
+
* Connection-level auth a strategy asks the browser context to carry. Produced by strategies whose
|
|
28
|
+
* scheme isn't reducible to cookies/localStorage (HTTP Basic, PAT headers, mTLS client certs).
|
|
29
|
+
* The session launcher passes these through to Playwright's `browser.newContext(...)`.
|
|
30
|
+
*/
|
|
31
|
+
export interface AuthContextOptions {
|
|
32
|
+
httpCredentials?: {
|
|
33
|
+
username: string;
|
|
34
|
+
password: string;
|
|
35
|
+
};
|
|
36
|
+
clientCertificates?: Array<Record<string, unknown>>;
|
|
37
|
+
extraHTTPHeaders?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
/** Result of authenticating a role: the captured session, plus an optional hard expiry the strategy knows. */
|
|
40
|
+
export interface AuthResult {
|
|
41
|
+
storageState: StorageState;
|
|
42
|
+
/** Epoch ms when the session is known to expire (e.g. from a cookie's `expires`). Optional. */
|
|
43
|
+
expiresAt?: number;
|
|
44
|
+
/** Connection-level auth (HTTP Basic / client certs / extra headers) for the browser context. */
|
|
45
|
+
contextOptions?: AuthContextOptions;
|
|
46
|
+
}
|
|
47
|
+
export interface AuthContext {
|
|
48
|
+
/** Credential values, resolved from the role's `creds_env` name map. Empty for `manual-capture`. */
|
|
49
|
+
creds: Record<string, string>;
|
|
50
|
+
/** Strategy-specific options from the descriptor. */
|
|
51
|
+
options: Record<string, unknown>;
|
|
52
|
+
/** The target site's base URL. */
|
|
53
|
+
baseURL: string;
|
|
54
|
+
/** Role name (for logging / cache keying). */
|
|
55
|
+
role: string;
|
|
56
|
+
/** Workspace root, for strategies that need workspace-rooted IO. Optional. */
|
|
57
|
+
workspaceDir?: string;
|
|
58
|
+
/** Parallel-worker index, for credential pools. Default 0. */
|
|
59
|
+
workerIndex?: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A strategy: given an {@link AuthContext}, produce an {@link AuthResult}. One implementation per
|
|
63
|
+
* backend type. `name` matches the descriptor's `strategy` value for built-ins; registry-registered
|
|
64
|
+
* plugin strategies may omit it (the registration name is canonical).
|
|
65
|
+
*/
|
|
66
|
+
export interface AuthStrategy {
|
|
67
|
+
readonly name?: string;
|
|
68
|
+
authenticate(ctx: AuthContext): Promise<AuthResult>;
|
|
69
|
+
}
|
|
70
|
+
export declare class NotImplementedStrategyError extends Error {
|
|
71
|
+
constructor(name: string);
|
|
72
|
+
}
|
|
73
|
+
export declare class AuthStrategyConfigError extends Error {
|
|
74
|
+
readonly cause?: unknown | undefined;
|
|
75
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
76
|
+
}
|
|
77
|
+
/** Validate a strategy's `options` record against its schema; config errors name the offending key, never values. */
|
|
78
|
+
export declare function parseStrategyOptions<S extends z.ZodTypeAny>(strategy: string, schema: S, options: Record<string, unknown>): z.infer<S>;
|
|
79
|
+
/** Mask a secret for logs / error messages — the value itself must never surface. */
|
|
80
|
+
export declare function maskSecret(value: string | undefined): "<SET>" | "<UNSET>";
|
|
81
|
+
/** Render a `{{key}}` template against a variable map. Unknown keys are left intact. */
|
|
82
|
+
export declare function renderTemplate(template: string, vars: Record<string, string>): string;
|
|
83
|
+
/** The earliest non-session cookie expiry (epoch ms), or `undefined` if all cookies are session cookies. */
|
|
84
|
+
export declare function earliestCookieExpiry(state: StorageState): number | undefined;
|
|
85
|
+
/** Expiry (epoch ms) of the cookie named `name` in `state` — the latest if there are several; `undefined` if absent or a session cookie (`expires <= 0`). */
|
|
86
|
+
export declare function cookieExpiryByName(state: StorageState, name: string): number | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Shared auth-layer types and StorageState helpers.
|
|
2
|
+
//
|
|
3
|
+
// Every strategy produces a `storageState` (cookies + localStorage + sessionStorage) — the
|
|
4
|
+
// universal artifact every auth scheme reduces to. Execution consumes it via Playwright's
|
|
5
|
+
// `setup`-project + `dependencies` mechanism (auth-agnostic for the rest of the suite).
|
|
6
|
+
/** An empty jar — for strategies whose "auth" is connection-level, not storage-level. */
|
|
7
|
+
export function emptyStorageState() {
|
|
8
|
+
return { cookies: [], origins: [] };
|
|
9
|
+
}
|
|
10
|
+
export class NotImplementedStrategyError extends Error {
|
|
11
|
+
constructor(name) {
|
|
12
|
+
super(`auth strategy "${name}" is not implemented in this build`);
|
|
13
|
+
this.name = "NotImplementedStrategyError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class AuthStrategyConfigError extends Error {
|
|
17
|
+
cause;
|
|
18
|
+
constructor(message, cause) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
this.name = "AuthStrategyConfigError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Validate a strategy's `options` record against its schema; config errors name the offending key, never values. */
|
|
25
|
+
export function parseStrategyOptions(strategy, schema, options) {
|
|
26
|
+
const r = schema.safeParse(options);
|
|
27
|
+
if (!r.success) {
|
|
28
|
+
const issues = r.error.issues
|
|
29
|
+
.map((i) => ` • options.${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
30
|
+
.join("\n");
|
|
31
|
+
throw new AuthStrategyConfigError(`strategy \`${strategy}\`: invalid options:\n${issues}`);
|
|
32
|
+
}
|
|
33
|
+
return r.data;
|
|
34
|
+
}
|
|
35
|
+
/** Mask a secret for logs / error messages — the value itself must never surface. */
|
|
36
|
+
export function maskSecret(value) {
|
|
37
|
+
return value ? "<SET>" : "<UNSET>";
|
|
38
|
+
}
|
|
39
|
+
/** Render a `{{key}}` template against a variable map. Unknown keys are left intact. */
|
|
40
|
+
export function renderTemplate(template, vars) {
|
|
41
|
+
return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, key) => key in vars ? vars[key] : whole);
|
|
42
|
+
}
|
|
43
|
+
/** The earliest non-session cookie expiry (epoch ms), or `undefined` if all cookies are session cookies. */
|
|
44
|
+
export function earliestCookieExpiry(state) {
|
|
45
|
+
const expiries = state.cookies
|
|
46
|
+
.map((c) => c.expires)
|
|
47
|
+
.filter((e) => typeof e === "number" && e > 0)
|
|
48
|
+
.map((e) => e * 1000); // Playwright cookie `expires` is seconds since epoch (or -1 for session)
|
|
49
|
+
return expiries.length ? Math.min(...expiries) : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Expiry (epoch ms) of the cookie named `name` in `state` — the latest if there are several; `undefined` if absent or a session cookie (`expires <= 0`). */
|
|
52
|
+
export function cookieExpiryByName(state, name) {
|
|
53
|
+
const expiries = state.cookies
|
|
54
|
+
.filter((c) => c.name === name && typeof c.expires === "number" && c.expires > 0)
|
|
55
|
+
.map((c) => c.expires * 1000);
|
|
56
|
+
return expiries.length ? Math.max(...expiries) : undefined;
|
|
57
|
+
}
|