@pasko70/pibo 1.10.1 → 1.11.1
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/dist/apps/chat/static-assets.js +10 -2
- package/dist/apps/chat/web-app.js +18 -2
- package/dist/apps/chat/workflow-manual-trigger-runtime.js +46 -149
- package/dist/apps/chat-ui/assets/{dist-BA6mhAec.js → dist-BBVpFHAq.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-hU-2oAb6.js → dist-BXyVMdHv.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BPl-fzRB.js → dist-BiGfVaXN.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BpfBSMzK.js → dist-CCGKu-Wj.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-uF6UXzI7.js → dist-CE0MvPLM.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-Cvx5M97R.js → dist-CuGiEm5l.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BPnn9e2B.js → dist-DBnh8gXR.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-r31x5Fcc.js → dist-DeOnZ-pw.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-4NlLjLs7.js → dist-DnQYnLQS.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DnXWNQRm.js → dist-VT4x40uL.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-3Sts0Afa.js → dist-xtnVygdr.js} +1 -1
- package/dist/apps/chat-ui/assets/{index-al8DeEjA.css → index-DNeE4HrG.css} +1 -1
- package/dist/apps/chat-ui/assets/{index-CaTGYBOS.js → index-vcg8JNj9.js} +69 -69
- package/dist/apps/chat-ui/favicon.svg +4 -0
- package/dist/apps/chat-ui/index.html +4 -2
- package/dist/apps/chat-ui/sw.js +12 -1
- package/dist/apps/chat-vscode-web/assets/index-Bf2JvJ9z.css +2 -0
- package/dist/apps/chat-vscode-web/assets/index-DVlCO7v_.js +41 -0
- package/dist/apps/chat-vscode-web/index.html +2 -2
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.11.1.vsix +0 -0
- package/dist/auth/better-auth.js +49 -1
- package/dist/auth/cli.js +220 -0
- package/dist/auth/machine-keys.js +258 -0
- package/dist/auth/machine-session.js +123 -0
- package/dist/bin/pibo.js +0 -0
- package/dist/bin/rg.js +0 -0
- package/dist/cli.js +6 -0
- package/dist/compute/cli.js +7 -0
- package/dist/compute/resource-health.js +27 -3
- package/dist/config/config.js +5 -0
- package/dist/core/session-router.js +17 -0
- package/dist/data/schema.js +4 -0
- package/dist/debug/trace-status.js +25 -0
- package/dist/debug/trace.js +9 -10
- package/dist/resources/cli.js +15 -0
- package/dist/resources/lifecycle.js +28 -4
- package/dist/resources/reaper.js +1 -0
- package/dist/session-ui/terminalRows.js +10 -0
- package/dist/shared/trace-event-projection.js +20 -3
- package/dist/shared/trace-transcript.js +4 -0
- package/dist/signals/projector.js +10 -1
- package/package.json +3 -5
- package/dist/apps/chat-vscode-web/assets/index-B5QK07zO.css +0 -2
- package/dist/apps/chat-vscode-web/assets/index-CK4SMZuu.js +0 -41
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<meta name="theme-color" content="#101d22" />
|
|
7
7
|
<title>Pibo</title>
|
|
8
|
-
<script type="module" crossorigin src="/apps/chat-vscode/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/apps/chat-vscode/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/apps/chat-vscode/assets/index-DVlCO7v_.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/apps/chat-vscode/assets/index-Bf2JvJ9z.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
Binary file
|
|
Binary file
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -6,6 +6,8 @@ import { getMigrations } from "better-auth/db/migration";
|
|
|
6
6
|
import { bearer } from "better-auth/plugins";
|
|
7
7
|
import { loadPiboConfig } from "../config/config.js";
|
|
8
8
|
import { piboHomePath } from "../core/pibo-home.js";
|
|
9
|
+
import { createMachineKeyAuthenticator } from "./machine-keys.js";
|
|
10
|
+
import { createMachineSessionManager } from "./machine-session.js";
|
|
9
11
|
import { createForbiddenAuthError, createUnauthenticatedError } from "./types.js";
|
|
10
12
|
const SESSION_EXPIRES_IN_SECONDS = 60 * 60 * 24 * 90;
|
|
11
13
|
const SESSION_UPDATE_AGE_SECONDS = 60 * 60;
|
|
@@ -64,6 +66,8 @@ export function createBetterAuthService(options = {}) {
|
|
|
64
66
|
const googleClientSecret = requiredOption(options.googleClientSecret ?? authConfig?.googleClientSecret, "auth.googleClientSecret");
|
|
65
67
|
const secret = requiredSecret(options.secret ?? authConfig?.secret);
|
|
66
68
|
const allowedEmails = requiredAllowedEmails(options, authConfig?.allowedEmails);
|
|
69
|
+
const machineKeys = createMachineKeyAuthenticator(options.machineKeyStorePath ?? authConfig?.machineKeyStorePath);
|
|
70
|
+
const machineSessions = createMachineSessionManager({ secret, machineKeys });
|
|
67
71
|
const database = createDatabase(options.databasePath ?? authConfig?.databasePath ?? piboHomePath("auth.sqlite"));
|
|
68
72
|
const trustedOrigins = options.trustedOrigins ?? authConfig?.trustedOrigins;
|
|
69
73
|
const authOptions = {
|
|
@@ -86,6 +90,12 @@ export function createBetterAuthService(options = {}) {
|
|
|
86
90
|
plugins: [bearer()],
|
|
87
91
|
};
|
|
88
92
|
const auth = betterAuth(authOptions);
|
|
93
|
+
const requireAllowedMachineSession = (session) => {
|
|
94
|
+
const email = session.identity.email?.toLowerCase();
|
|
95
|
+
if (!email || !allowedEmails.has(email))
|
|
96
|
+
throw createForbiddenAuthError();
|
|
97
|
+
return session;
|
|
98
|
+
};
|
|
89
99
|
return {
|
|
90
100
|
name: "better-auth",
|
|
91
101
|
async start() {
|
|
@@ -96,6 +106,9 @@ export function createBetterAuthService(options = {}) {
|
|
|
96
106
|
database.close();
|
|
97
107
|
},
|
|
98
108
|
async getSession(headers) {
|
|
109
|
+
const machineSession = machineKeys.getSession(headers) ?? machineSessions.getSession(headers);
|
|
110
|
+
if (machineSession)
|
|
111
|
+
return requireAllowedMachineSession(machineSession);
|
|
99
112
|
const session = await auth.api.getSession({ headers });
|
|
100
113
|
if (!session)
|
|
101
114
|
return undefined;
|
|
@@ -123,7 +136,42 @@ export function createBetterAuthService(options = {}) {
|
|
|
123
136
|
throw createUnauthenticatedError();
|
|
124
137
|
return session;
|
|
125
138
|
},
|
|
126
|
-
handleRequest(request) {
|
|
139
|
+
async handleRequest(request) {
|
|
140
|
+
const url = new URL(request.url);
|
|
141
|
+
if (url.pathname === "/api/auth/machine-session") {
|
|
142
|
+
if (request.method === "DELETE") {
|
|
143
|
+
return new Response(null, {
|
|
144
|
+
status: 204,
|
|
145
|
+
headers: { "set-cookie": machineSessions.clearHeader(), "cache-control": "no-store" },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (request.method !== "POST") {
|
|
149
|
+
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
150
|
+
status: 405,
|
|
151
|
+
headers: { "content-type": "application/json", allow: "POST, DELETE", "cache-control": "no-store" },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
155
|
+
if (url.protocol !== "https:" && !loopbackHosts.has(url.hostname)) {
|
|
156
|
+
return new Response(JSON.stringify({ error: "Machine session exchange requires HTTPS outside loopback" }), {
|
|
157
|
+
status: 400,
|
|
158
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const authentication = machineKeys.authenticate(request.headers);
|
|
162
|
+
if (!authentication)
|
|
163
|
+
throw createUnauthenticatedError();
|
|
164
|
+
const session = requireAllowedMachineSession(authentication.session);
|
|
165
|
+
const created = machineSessions.create({ ...authentication, session });
|
|
166
|
+
return new Response(JSON.stringify({ identity: created.session.identity, expiresAt: created.expiresAt.toISOString() }), {
|
|
167
|
+
status: 200,
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"cache-control": "no-store",
|
|
171
|
+
"set-cookie": created.header,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
127
175
|
return auth.handler(request);
|
|
128
176
|
},
|
|
129
177
|
};
|
package/dist/auth/cli.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { loadPiboConfig } from "../config/config.js";
|
|
6
|
+
import { piboHomePath } from "../core/pibo-home.js";
|
|
7
|
+
import { generateMachineKey, getDefaultMachineKeyStorePath, importMachineKeyRecord, listMachineKeys, parseMachineKeyRecord, revokeMachineKey, } from "./machine-keys.js";
|
|
8
|
+
function printJson(value) {
|
|
9
|
+
console.log(JSON.stringify(value, null, 2));
|
|
10
|
+
}
|
|
11
|
+
function printAuthDiscovery() {
|
|
12
|
+
console.log(`pibo auth - manage Web authentication
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
machine-key Manage revocable machine identities
|
|
16
|
+
|
|
17
|
+
Next:
|
|
18
|
+
pibo auth machine-key
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
function printMachineKeyDiscovery() {
|
|
22
|
+
console.log(`pibo auth machine-key - manage machine identities
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
identity Resolve one existing user from the allowed email set
|
|
26
|
+
generate Generate local secret and hash-only record files
|
|
27
|
+
import Import one hash-only record into the server store
|
|
28
|
+
list List keys without hashes or secrets
|
|
29
|
+
revoke Revoke one key by id
|
|
30
|
+
|
|
31
|
+
Next:
|
|
32
|
+
pibo auth machine-key <command> --help
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
function readJsonInput(path) {
|
|
36
|
+
const text = path === "-" ? readFileSync(0, "utf8") : readFileSync(resolve(path), "utf8");
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
function parseIdentity(value) {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
41
|
+
throw new Error("Identity input must be a JSON object");
|
|
42
|
+
}
|
|
43
|
+
const input = value;
|
|
44
|
+
if (typeof input.userId !== "string" || input.userId.length === 0) {
|
|
45
|
+
throw new Error("Identity input requires userId");
|
|
46
|
+
}
|
|
47
|
+
if (typeof input.email !== "string" || input.email.length === 0) {
|
|
48
|
+
throw new Error("Identity input requires email");
|
|
49
|
+
}
|
|
50
|
+
for (const field of ["name", "image"]) {
|
|
51
|
+
if (input[field] !== undefined && input[field] !== null && typeof input[field] !== "string") {
|
|
52
|
+
throw new Error(`Identity input ${field} must be a string when present`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
userId: input.userId,
|
|
57
|
+
email: input.email,
|
|
58
|
+
...(typeof input.name === "string" && input.name.length > 0 ? { name: input.name } : {}),
|
|
59
|
+
...(typeof input.image === "string" && input.image.length > 0 ? { image: input.image } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function configuredAuthDatabasePath() {
|
|
63
|
+
return resolve(loadPiboConfig().auth?.databasePath ?? piboHomePath("auth.sqlite"));
|
|
64
|
+
}
|
|
65
|
+
function resolveAllowedIdentity(email) {
|
|
66
|
+
const allowedEmails = new Set((loadPiboConfig().auth?.allowedEmails ?? []).map((candidate) => candidate.trim().toLowerCase()).filter(Boolean));
|
|
67
|
+
const normalizedEmail = email?.trim().toLowerCase();
|
|
68
|
+
if (normalizedEmail && !allowedEmails.has(normalizedEmail)) {
|
|
69
|
+
throw new Error(`Email "${email}" is not present in auth.allowedEmails`);
|
|
70
|
+
}
|
|
71
|
+
const database = new DatabaseSync(configuredAuthDatabasePath(), { readOnly: true });
|
|
72
|
+
try {
|
|
73
|
+
const rows = database.prepare('SELECT id, email, name, image FROM "user"').all();
|
|
74
|
+
const matches = rows.filter((row) => typeof row.id === "string" &&
|
|
75
|
+
typeof row.email === "string" &&
|
|
76
|
+
allowedEmails.has(row.email.toLowerCase()) &&
|
|
77
|
+
(normalizedEmail === undefined || row.email.toLowerCase() === normalizedEmail));
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
throw new Error(normalizedEmail
|
|
80
|
+
? `No Better Auth user exists for allowed email "${email}"`
|
|
81
|
+
: "No Better Auth user matches auth.allowedEmails");
|
|
82
|
+
}
|
|
83
|
+
if (matches.length > 1) {
|
|
84
|
+
throw new Error("Multiple Better Auth users match auth.allowedEmails; pass --email to select one");
|
|
85
|
+
}
|
|
86
|
+
const row = matches[0];
|
|
87
|
+
return {
|
|
88
|
+
userId: row.id,
|
|
89
|
+
email: row.email,
|
|
90
|
+
...(typeof row.name === "string" && row.name.length > 0 ? { name: row.name } : {}),
|
|
91
|
+
...(typeof row.image === "string" && row.image.length > 0 ? { image: row.image } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
database.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function requireNewOutputPath(path, label) {
|
|
99
|
+
const resolvedPath = resolve(path);
|
|
100
|
+
if (existsSync(resolvedPath))
|
|
101
|
+
throw new Error(`${label} already exists: ${resolvedPath}`);
|
|
102
|
+
return resolvedPath;
|
|
103
|
+
}
|
|
104
|
+
function writePrivateNewFile(path, content) {
|
|
105
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(path, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
107
|
+
}
|
|
108
|
+
function parseExpiresAt(value) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const timestamp = Date.parse(value);
|
|
112
|
+
if (Number.isNaN(timestamp))
|
|
113
|
+
throw new Error("--expires-at must be an ISO timestamp");
|
|
114
|
+
if (timestamp <= Date.now())
|
|
115
|
+
throw new Error("--expires-at must be in the future");
|
|
116
|
+
return new Date(timestamp).toISOString();
|
|
117
|
+
}
|
|
118
|
+
export async function runAuthCli(argv = process.argv) {
|
|
119
|
+
if (argv.length <= 2 || argv[2] === "--help" || argv[2] === "-h") {
|
|
120
|
+
printAuthDiscovery();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (argv[2] === "machine-key" && (argv.length <= 3 || argv[3] === "--help" || argv[3] === "-h")) {
|
|
124
|
+
printMachineKeyDiscovery();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const program = new Command();
|
|
128
|
+
program.name("pibo auth").description("Manage Pibo Web authentication");
|
|
129
|
+
const machineKey = program.command("machine-key").description("Manage revocable machine identities");
|
|
130
|
+
machineKey
|
|
131
|
+
.command("identity")
|
|
132
|
+
.description("Resolve an existing allowed Better Auth user")
|
|
133
|
+
.option("--email <email>", "Select one allowed Google account email when multiple users match")
|
|
134
|
+
.option("--json", "Print JSON")
|
|
135
|
+
.action((options) => {
|
|
136
|
+
const identity = resolveAllowedIdentity(options.email);
|
|
137
|
+
if (options.json)
|
|
138
|
+
printJson(identity);
|
|
139
|
+
else
|
|
140
|
+
console.log(`${identity.userId}\t${identity.email}\t${identity.name ?? ""}`);
|
|
141
|
+
});
|
|
142
|
+
machineKey
|
|
143
|
+
.command("generate")
|
|
144
|
+
.description("Generate a local raw secret and hash-only import record")
|
|
145
|
+
.requiredOption("--identity-file <path>", "Identity JSON from the identity command")
|
|
146
|
+
.requiredOption("--label <label>", "Operator label for this machine")
|
|
147
|
+
.requiredOption("--secret-output <path>", "New root-only raw-secret file")
|
|
148
|
+
.requiredOption("--record-output <path>", "New root-only hash-record file")
|
|
149
|
+
.option("--expires-at <timestamp>", "Optional future ISO expiration timestamp")
|
|
150
|
+
.action((options) => {
|
|
151
|
+
const secretOutput = requireNewOutputPath(options.secretOutput, "Secret output");
|
|
152
|
+
const recordOutput = requireNewOutputPath(options.recordOutput, "Record output");
|
|
153
|
+
if (secretOutput === recordOutput)
|
|
154
|
+
throw new Error("Secret and record output paths must differ");
|
|
155
|
+
const identity = parseIdentity(readJsonInput(options.identityFile));
|
|
156
|
+
const generated = generateMachineKey({
|
|
157
|
+
label: options.label,
|
|
158
|
+
identity,
|
|
159
|
+
expiresAt: parseExpiresAt(options.expiresAt),
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
writePrivateNewFile(secretOutput, `${generated.token}\n`);
|
|
163
|
+
writePrivateNewFile(recordOutput, `${JSON.stringify(generated.record, null, 2)}\n`);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (existsSync(secretOutput))
|
|
167
|
+
unlinkSync(secretOutput);
|
|
168
|
+
if (existsSync(recordOutput))
|
|
169
|
+
unlinkSync(recordOutput);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
printJson({
|
|
173
|
+
id: generated.record.id,
|
|
174
|
+
label: generated.record.label,
|
|
175
|
+
email: generated.record.identity.email,
|
|
176
|
+
expiresAt: generated.record.expiresAt,
|
|
177
|
+
secretOutput,
|
|
178
|
+
recordOutput,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
machineKey
|
|
182
|
+
.command("import")
|
|
183
|
+
.description("Import one hash-only machine-key record")
|
|
184
|
+
.requiredOption("--file <path>", "Record JSON path, or - for stdin")
|
|
185
|
+
.option("--store <path>", "Alternate machine-key store path")
|
|
186
|
+
.action((options) => {
|
|
187
|
+
const parsed = parseMachineKeyRecord(readJsonInput(options.file));
|
|
188
|
+
const existingIdentity = resolveAllowedIdentity(parsed.identity.email);
|
|
189
|
+
if (existingIdentity.userId !== parsed.identity.userId) {
|
|
190
|
+
throw new Error(`Machine identity userId does not match the Better Auth user for "${parsed.identity.email}"`);
|
|
191
|
+
}
|
|
192
|
+
const imported = importMachineKeyRecord(parsed, options.store ?? getDefaultMachineKeyStorePath());
|
|
193
|
+
printJson({ id: imported.id, label: imported.label, email: imported.identity.email, createdAt: imported.createdAt });
|
|
194
|
+
});
|
|
195
|
+
machineKey
|
|
196
|
+
.command("list")
|
|
197
|
+
.description("List machine keys without hashes or secrets")
|
|
198
|
+
.option("--store <path>", "Alternate machine-key store path")
|
|
199
|
+
.option("--json", "Print JSON")
|
|
200
|
+
.action((options) => {
|
|
201
|
+
const keys = listMachineKeys(options.store ?? getDefaultMachineKeyStorePath());
|
|
202
|
+
if (options.json) {
|
|
203
|
+
printJson(keys);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const key of keys) {
|
|
207
|
+
console.log(`${key.id}\t${key.status}\t${key.identity.email ?? ""}\t${key.label}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
machineKey
|
|
211
|
+
.command("revoke")
|
|
212
|
+
.description("Revoke one machine key")
|
|
213
|
+
.argument("<id>", "Machine-key id")
|
|
214
|
+
.option("--store <path>", "Alternate machine-key store path")
|
|
215
|
+
.action((id, options) => {
|
|
216
|
+
const record = revokeMachineKey(id, options.store ?? getDefaultMachineKeyStorePath());
|
|
217
|
+
printJson({ id: record.id, label: record.label, revokedAt: record.revokedAt });
|
|
218
|
+
});
|
|
219
|
+
await program.parseAsync(argv);
|
|
220
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { piboHomePath } from "../core/pibo-home.js";
|
|
5
|
+
export const PIBO_MACHINE_KEY_HEADER = "x-pibo-machine-key";
|
|
6
|
+
export const MACHINE_KEY_STORE_VERSION = 1;
|
|
7
|
+
const MACHINE_KEY_ID_PATTERN = /^[a-f0-9]{16}$/;
|
|
8
|
+
const MACHINE_KEY_HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
9
|
+
const MACHINE_KEY_TOKEN_PATTERN = /^pibo_mk_([a-f0-9]{16})_([A-Za-z0-9_-]{43})$/;
|
|
10
|
+
const MACHINE_KEY_LABEL_MAX_LENGTH = 120;
|
|
11
|
+
export function getDefaultMachineKeyStorePath() {
|
|
12
|
+
return piboHomePath("machine-keys.json");
|
|
13
|
+
}
|
|
14
|
+
function hashMachineKeyToken(token) {
|
|
15
|
+
return createHash("sha256").update(token, "utf8").digest();
|
|
16
|
+
}
|
|
17
|
+
function parseIsoTimestamp(value, field) {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0 || Number.isNaN(Date.parse(value))) {
|
|
19
|
+
throw new Error(`${field} must be an ISO timestamp`);
|
|
20
|
+
}
|
|
21
|
+
return new Date(value).toISOString();
|
|
22
|
+
}
|
|
23
|
+
function optionalString(value, field) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (typeof value !== "string" || value.length === 0)
|
|
27
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function parseIdentity(value) {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
32
|
+
throw new Error("identity must be an object");
|
|
33
|
+
}
|
|
34
|
+
const input = value;
|
|
35
|
+
const allowedKeys = new Set(["userId", "email", "name", "image", "provider"]);
|
|
36
|
+
for (const key of Object.keys(input)) {
|
|
37
|
+
if (!allowedKeys.has(key))
|
|
38
|
+
throw new Error(`identity contains unsupported field "${key}"`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof input.userId !== "string" || input.userId.length === 0) {
|
|
41
|
+
throw new Error("identity.userId must be a non-empty string");
|
|
42
|
+
}
|
|
43
|
+
if (input.provider !== "machine-key") {
|
|
44
|
+
throw new Error('identity.provider must be "machine-key"');
|
|
45
|
+
}
|
|
46
|
+
const email = optionalString(input.email, "identity.email");
|
|
47
|
+
if (!email)
|
|
48
|
+
throw new Error("identity.email must be a non-empty string");
|
|
49
|
+
return {
|
|
50
|
+
userId: input.userId,
|
|
51
|
+
email,
|
|
52
|
+
name: optionalString(input.name, "identity.name"),
|
|
53
|
+
image: optionalString(input.image, "identity.image"),
|
|
54
|
+
provider: "machine-key",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function parseMachineKeyRecord(value) {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
59
|
+
throw new Error("Machine-key record must be an object");
|
|
60
|
+
}
|
|
61
|
+
const input = value;
|
|
62
|
+
const allowedKeys = new Set(["id", "label", "hash", "identity", "createdAt", "expiresAt", "revokedAt"]);
|
|
63
|
+
for (const key of Object.keys(input)) {
|
|
64
|
+
if (!allowedKeys.has(key))
|
|
65
|
+
throw new Error(`Machine-key record contains unsupported field "${key}"`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof input.id !== "string" || !MACHINE_KEY_ID_PATTERN.test(input.id)) {
|
|
68
|
+
throw new Error("Machine-key record id must be 16 lowercase hexadecimal characters");
|
|
69
|
+
}
|
|
70
|
+
if (typeof input.label !== "string" ||
|
|
71
|
+
input.label.trim().length === 0 ||
|
|
72
|
+
input.label.trim().length > MACHINE_KEY_LABEL_MAX_LENGTH) {
|
|
73
|
+
throw new Error(`Machine-key record label must contain 1-${MACHINE_KEY_LABEL_MAX_LENGTH} characters`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof input.hash !== "string" || !MACHINE_KEY_HASH_PATTERN.test(input.hash)) {
|
|
76
|
+
throw new Error("Machine-key record hash must be 64 lowercase hexadecimal characters");
|
|
77
|
+
}
|
|
78
|
+
const createdAt = parseIsoTimestamp(input.createdAt, "createdAt");
|
|
79
|
+
const expiresAt = input.expiresAt === undefined ? undefined : parseIsoTimestamp(input.expiresAt, "expiresAt");
|
|
80
|
+
const revokedAt = input.revokedAt === undefined ? undefined : parseIsoTimestamp(input.revokedAt, "revokedAt");
|
|
81
|
+
return {
|
|
82
|
+
id: input.id,
|
|
83
|
+
label: input.label.trim(),
|
|
84
|
+
hash: input.hash,
|
|
85
|
+
identity: parseIdentity(input.identity),
|
|
86
|
+
createdAt,
|
|
87
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
88
|
+
...(revokedAt ? { revokedAt } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function parseMachineKeyStore(value) {
|
|
92
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
93
|
+
throw new Error("Machine-key store must be an object");
|
|
94
|
+
}
|
|
95
|
+
const input = value;
|
|
96
|
+
if (input.version !== MACHINE_KEY_STORE_VERSION) {
|
|
97
|
+
throw new Error(`Machine-key store version must be ${MACHINE_KEY_STORE_VERSION}`);
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(input.keys))
|
|
100
|
+
throw new Error("Machine-key store keys must be an array");
|
|
101
|
+
const keys = input.keys.map(parseMachineKeyRecord);
|
|
102
|
+
const ids = new Set();
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
if (ids.has(key.id))
|
|
105
|
+
throw new Error(`Duplicate machine-key id "${key.id}"`);
|
|
106
|
+
ids.add(key.id);
|
|
107
|
+
}
|
|
108
|
+
return { version: MACHINE_KEY_STORE_VERSION, keys };
|
|
109
|
+
}
|
|
110
|
+
function assertPrivateStoreFile(path) {
|
|
111
|
+
if (process.platform === "win32")
|
|
112
|
+
return;
|
|
113
|
+
const stat = statSync(path);
|
|
114
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
115
|
+
throw new Error(`Machine-key store must not be accessible by group or other users: ${path}`);
|
|
116
|
+
}
|
|
117
|
+
const currentUid = process.getuid?.();
|
|
118
|
+
if (currentUid !== undefined && stat.uid !== currentUid) {
|
|
119
|
+
throw new Error(`Machine-key store must be owned by the current user: ${path}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function readMachineKeyStore(path = getDefaultMachineKeyStorePath()) {
|
|
123
|
+
const resolvedPath = resolve(path);
|
|
124
|
+
if (!existsSync(resolvedPath))
|
|
125
|
+
return { version: MACHINE_KEY_STORE_VERSION, keys: [] };
|
|
126
|
+
assertPrivateStoreFile(resolvedPath);
|
|
127
|
+
return parseMachineKeyStore(JSON.parse(readFileSync(resolvedPath, "utf8")));
|
|
128
|
+
}
|
|
129
|
+
function writeMachineKeyStore(store, path) {
|
|
130
|
+
const resolvedPath = resolve(path);
|
|
131
|
+
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
132
|
+
const temporaryPath = `${resolvedPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
133
|
+
try {
|
|
134
|
+
writeFileSync(temporaryPath, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
135
|
+
renameSync(temporaryPath, resolvedPath);
|
|
136
|
+
chmodSync(resolvedPath, 0o600);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (existsSync(temporaryPath))
|
|
140
|
+
unlinkSync(temporaryPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function generateMachineKey(options) {
|
|
144
|
+
const id = randomBytes(8).toString("hex");
|
|
145
|
+
const secret = randomBytes(32).toString("base64url");
|
|
146
|
+
const token = `pibo_mk_${id}_${secret}`;
|
|
147
|
+
const now = options.now ?? new Date();
|
|
148
|
+
const expiresAt = options.expiresAt === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: typeof options.expiresAt === "string"
|
|
151
|
+
? parseIsoTimestamp(options.expiresAt, "expiresAt")
|
|
152
|
+
: options.expiresAt.toISOString();
|
|
153
|
+
const record = parseMachineKeyRecord({
|
|
154
|
+
id,
|
|
155
|
+
label: options.label,
|
|
156
|
+
hash: hashMachineKeyToken(token).toString("hex"),
|
|
157
|
+
identity: { ...options.identity, provider: "machine-key" },
|
|
158
|
+
createdAt: now.toISOString(),
|
|
159
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
160
|
+
});
|
|
161
|
+
return { token, record };
|
|
162
|
+
}
|
|
163
|
+
export function importMachineKeyRecord(recordValue, path = getDefaultMachineKeyStorePath()) {
|
|
164
|
+
const record = parseMachineKeyRecord(recordValue);
|
|
165
|
+
const store = readMachineKeyStore(path);
|
|
166
|
+
if (store.keys.some((candidate) => candidate.id === record.id)) {
|
|
167
|
+
throw new Error(`Machine-key id "${record.id}" already exists`);
|
|
168
|
+
}
|
|
169
|
+
store.keys.push(record);
|
|
170
|
+
writeMachineKeyStore(store, path);
|
|
171
|
+
return record;
|
|
172
|
+
}
|
|
173
|
+
function machineKeyStatus(record, now) {
|
|
174
|
+
if (record.revokedAt)
|
|
175
|
+
return "revoked";
|
|
176
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= now.getTime())
|
|
177
|
+
return "expired";
|
|
178
|
+
return "active";
|
|
179
|
+
}
|
|
180
|
+
export function listMachineKeys(path = getDefaultMachineKeyStorePath(), now = new Date()) {
|
|
181
|
+
return readMachineKeyStore(path).keys.map((record) => {
|
|
182
|
+
const { hash: _hash, ...listed } = record;
|
|
183
|
+
return { ...listed, status: machineKeyStatus(record, now) };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
export function revokeMachineKey(id, path = getDefaultMachineKeyStorePath(), now = new Date()) {
|
|
187
|
+
if (!MACHINE_KEY_ID_PATTERN.test(id))
|
|
188
|
+
throw new Error("Machine-key id must be 16 lowercase hexadecimal characters");
|
|
189
|
+
const store = readMachineKeyStore(path);
|
|
190
|
+
const index = store.keys.findIndex((candidate) => candidate.id === id);
|
|
191
|
+
if (index < 0)
|
|
192
|
+
throw new Error(`Unknown machine-key id "${id}"`);
|
|
193
|
+
const current = store.keys[index];
|
|
194
|
+
const revoked = { ...current, revokedAt: current.revokedAt ?? now.toISOString() };
|
|
195
|
+
store.keys[index] = revoked;
|
|
196
|
+
writeMachineKeyStore(store, path);
|
|
197
|
+
return revoked;
|
|
198
|
+
}
|
|
199
|
+
function storeSignature(path) {
|
|
200
|
+
try {
|
|
201
|
+
const stat = statSync(path);
|
|
202
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error.code === "ENOENT")
|
|
206
|
+
return "missing";
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function createMachineKeyAuthenticator(path = getDefaultMachineKeyStorePath()) {
|
|
211
|
+
const resolvedPath = resolve(path);
|
|
212
|
+
let cachedSignature;
|
|
213
|
+
let keysById = new Map();
|
|
214
|
+
const refresh = () => {
|
|
215
|
+
const signature = storeSignature(resolvedPath);
|
|
216
|
+
if (signature === cachedSignature)
|
|
217
|
+
return;
|
|
218
|
+
const store = readMachineKeyStore(resolvedPath);
|
|
219
|
+
keysById = new Map(store.keys.map((record) => [record.id, record]));
|
|
220
|
+
cachedSignature = signature;
|
|
221
|
+
};
|
|
222
|
+
const sessionForRecord = (record) => ({
|
|
223
|
+
identity: { ...record.identity },
|
|
224
|
+
sessionId: `machine-key:${record.id}`,
|
|
225
|
+
...(record.expiresAt ? { expiresAt: new Date(record.expiresAt) } : {}),
|
|
226
|
+
});
|
|
227
|
+
const getSessionById = (id) => {
|
|
228
|
+
refresh();
|
|
229
|
+
const record = keysById.get(id);
|
|
230
|
+
if (!record || machineKeyStatus(record, new Date()) !== "active")
|
|
231
|
+
return undefined;
|
|
232
|
+
return sessionForRecord(record);
|
|
233
|
+
};
|
|
234
|
+
const authenticate = (headers) => {
|
|
235
|
+
const token = headers.get(PIBO_MACHINE_KEY_HEADER)?.trim();
|
|
236
|
+
if (!token)
|
|
237
|
+
return undefined;
|
|
238
|
+
const match = MACHINE_KEY_TOKEN_PATTERN.exec(token);
|
|
239
|
+
if (!match)
|
|
240
|
+
return undefined;
|
|
241
|
+
refresh();
|
|
242
|
+
const record = keysById.get(match[1]);
|
|
243
|
+
if (!record || machineKeyStatus(record, new Date()) !== "active")
|
|
244
|
+
return undefined;
|
|
245
|
+
const expected = Buffer.from(record.hash, "hex");
|
|
246
|
+
const actual = hashMachineKeyToken(token);
|
|
247
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
248
|
+
return undefined;
|
|
249
|
+
return { id: record.id, session: sessionForRecord(record) };
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
authenticate,
|
|
253
|
+
getSession(headers) {
|
|
254
|
+
return authenticate(headers)?.session;
|
|
255
|
+
},
|
|
256
|
+
getSessionById,
|
|
257
|
+
};
|
|
258
|
+
}
|