@treeseed/core 0.4.1 → 0.4.4
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 +7 -1
- package/dist/api/agent-routes.d.ts +13 -0
- package/dist/api/agent-routes.js +402 -0
- package/dist/api/app.d.ts +5 -0
- package/dist/api/app.js +270 -0
- package/dist/api/auth/d1-database.d.ts +3 -0
- package/dist/api/auth/d1-database.js +24 -0
- package/dist/api/auth/d1-provider.d.ts +67 -0
- package/dist/api/auth/d1-provider.js +84 -0
- package/dist/api/auth/d1-store.d.ts +97 -0
- package/dist/api/auth/d1-store.js +631 -0
- package/dist/api/auth/memory-provider.d.ts +73 -0
- package/dist/api/auth/memory-provider.js +239 -0
- package/dist/api/auth/rbac.d.ts +22 -0
- package/dist/api/auth/rbac.js +158 -0
- package/dist/api/auth/tokens.d.ts +18 -0
- package/dist/api/auth/tokens.js +56 -0
- package/dist/api/config.d.ts +2 -0
- package/dist/api/config.js +65 -0
- package/dist/api/gateway.d.ts +5 -0
- package/dist/api/gateway.js +35 -0
- package/dist/api/http.d.ts +24 -0
- package/dist/api/http.js +44 -0
- package/dist/api/index.d.ts +9 -0
- package/dist/api/index.js +18 -0
- package/dist/api/operations-routes.d.ts +6 -0
- package/dist/api/operations-routes.js +34 -0
- package/dist/api/operations.d.ts +3 -0
- package/dist/api/operations.js +26 -0
- package/dist/api/providers.d.ts +2 -0
- package/dist/api/providers.js +61 -0
- package/dist/api/railway.d.ts +45 -0
- package/dist/api/railway.js +69 -0
- package/dist/api/sdk-dispatch.d.ts +14 -0
- package/dist/api/sdk-dispatch.js +145 -0
- package/dist/api/sdk-routes.d.ts +10 -0
- package/dist/api/sdk-routes.js +25 -0
- package/dist/api/server.d.ts +2 -0
- package/dist/api/server.js +10 -0
- package/dist/api/templates.d.ts +3 -0
- package/dist/api/templates.js +31 -0
- package/dist/api/types.d.ts +193 -0
- package/dist/api/types.js +0 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/dev.d.ts +41 -0
- package/dist/dev.js +189 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +23 -1
- package/dist/platform-resources.d.ts +37 -0
- package/dist/platform-resources.js +133 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +16 -0
- package/dist/plugin-default.d.ts +1 -0
- package/dist/plugin-default.js +4 -0
- package/dist/railway.d.ts +1 -0
- package/dist/railway.js +4 -0
- package/dist/scripts/build-dist.js +7 -0
- package/dist/scripts/dev-platform.js +24 -0
- package/dist/scripts/workspace-bootstrap.js +24 -10
- package/dist/site-resources.d.ts +1 -29
- package/dist/site-resources.js +7 -120
- package/dist/site.js +3 -1
- package/package.json +37 -3
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { DEFAULT_PERMISSIONS, DEFAULT_ROLES } from "./rbac.js";
|
|
3
|
+
import { createAccessToken, nextOpaqueToken, principalFromAccessTokenPayload, verifyAccessToken } from "./tokens.js";
|
|
4
|
+
function now() {
|
|
5
|
+
return /* @__PURE__ */ new Date();
|
|
6
|
+
}
|
|
7
|
+
function isoNow() {
|
|
8
|
+
return now().toISOString();
|
|
9
|
+
}
|
|
10
|
+
function addSeconds(date, seconds) {
|
|
11
|
+
return new Date(date.getTime() + seconds * 1e3);
|
|
12
|
+
}
|
|
13
|
+
function parseJson(value, fallback) {
|
|
14
|
+
if (!value) return fallback;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(value);
|
|
17
|
+
} catch {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function stableHash(value, secret) {
|
|
22
|
+
return createHash("sha256").update(`${secret}:${value}`).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
function equalHash(left, right) {
|
|
25
|
+
const leftBuffer = Buffer.from(left);
|
|
26
|
+
const rightBuffer = Buffer.from(right);
|
|
27
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
28
|
+
}
|
|
29
|
+
class D1AuthStore {
|
|
30
|
+
constructor(config, db) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.db = db;
|
|
33
|
+
}
|
|
34
|
+
config;
|
|
35
|
+
db;
|
|
36
|
+
initializationPromise = null;
|
|
37
|
+
async run(query, params = []) {
|
|
38
|
+
await this.db.prepare(query).bind(...params).run();
|
|
39
|
+
}
|
|
40
|
+
async first(query, params = []) {
|
|
41
|
+
return this.db.prepare(query).bind(...params).first();
|
|
42
|
+
}
|
|
43
|
+
async all(query, params = []) {
|
|
44
|
+
const result = await this.db.prepare(query).bind(...params).all();
|
|
45
|
+
return result.results ?? [];
|
|
46
|
+
}
|
|
47
|
+
ensureInitialized() {
|
|
48
|
+
if (!this.initializationPromise) {
|
|
49
|
+
this.initializationPromise = this.seedCatalog().then(() => this.seedConfiguredServices());
|
|
50
|
+
}
|
|
51
|
+
return this.initializationPromise;
|
|
52
|
+
}
|
|
53
|
+
async seedCatalog() {
|
|
54
|
+
const createdAt = isoNow();
|
|
55
|
+
for (const permission of DEFAULT_PERMISSIONS) {
|
|
56
|
+
await this.run(
|
|
57
|
+
`INSERT OR IGNORE INTO permissions (id, key, resource, action, scope, description, created_at)
|
|
58
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
59
|
+
[randomUUID(), permission.key, permission.resource, permission.action, permission.scope, permission.description, createdAt]
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
for (const role of DEFAULT_ROLES) {
|
|
63
|
+
await this.run(
|
|
64
|
+
`INSERT OR IGNORE INTO roles (id, key, description, created_at)
|
|
65
|
+
VALUES (?, ?, ?, ?)`,
|
|
66
|
+
[randomUUID(), role.key, role.description, createdAt]
|
|
67
|
+
);
|
|
68
|
+
const roleRow = await this.first(`SELECT id FROM roles WHERE key = ?`, [role.key]);
|
|
69
|
+
if (!roleRow) continue;
|
|
70
|
+
for (const permissionKey of role.permissions) {
|
|
71
|
+
const permissionRow = await this.first(`SELECT id FROM permissions WHERE key = ?`, [permissionKey]);
|
|
72
|
+
if (permissionRow) {
|
|
73
|
+
await this.run(
|
|
74
|
+
`INSERT OR IGNORE INTO role_permissions (role_id, permission_id, created_at)
|
|
75
|
+
VALUES (?, ?, ?)`,
|
|
76
|
+
[roleRow.id, permissionRow.id, createdAt]
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async seedConfiguredServices() {
|
|
83
|
+
if (!this.config.webServiceSecret) return;
|
|
84
|
+
await this.upsertServiceCredential({
|
|
85
|
+
serviceId: this.config.webServiceId,
|
|
86
|
+
name: "Trusted web tier",
|
|
87
|
+
secret: this.config.webServiceSecret,
|
|
88
|
+
roles: ["market_admin"],
|
|
89
|
+
permissions: ["services:impersonate:global"]
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async loadUser(userId) {
|
|
93
|
+
return this.first(`SELECT * FROM users WHERE id = ?`, [userId]);
|
|
94
|
+
}
|
|
95
|
+
async loadIdentityByProvider(provider, providerSubject) {
|
|
96
|
+
return this.first(
|
|
97
|
+
`SELECT * FROM user_identities WHERE provider = ? AND provider_subject = ?`,
|
|
98
|
+
[provider, providerSubject]
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
async rolesForUser(userId) {
|
|
102
|
+
const rows = await this.all(
|
|
103
|
+
`SELECT roles.key AS key
|
|
104
|
+
FROM user_role_bindings
|
|
105
|
+
INNER JOIN roles ON roles.id = user_role_bindings.role_id
|
|
106
|
+
WHERE user_role_bindings.user_id = ?`,
|
|
107
|
+
[userId]
|
|
108
|
+
);
|
|
109
|
+
return rows.map((row) => row.key);
|
|
110
|
+
}
|
|
111
|
+
async permissionsForUser(userId) {
|
|
112
|
+
const rows = await this.all(
|
|
113
|
+
`SELECT DISTINCT permissions.key AS key
|
|
114
|
+
FROM user_role_bindings
|
|
115
|
+
INNER JOIN role_permissions ON role_permissions.role_id = user_role_bindings.role_id
|
|
116
|
+
INNER JOIN permissions ON permissions.id = role_permissions.permission_id
|
|
117
|
+
WHERE user_role_bindings.user_id = ?`,
|
|
118
|
+
[userId]
|
|
119
|
+
);
|
|
120
|
+
return rows.map((row) => row.key);
|
|
121
|
+
}
|
|
122
|
+
scopesForPrincipal(permissions) {
|
|
123
|
+
const scopes = /* @__PURE__ */ new Set(["auth:me"]);
|
|
124
|
+
if (permissions.includes("*:*:*") || permissions.includes("sdk:execute:global")) scopes.add("sdk");
|
|
125
|
+
if (permissions.includes("*:*:*") || permissions.includes("agent:execute:global")) scopes.add("agent");
|
|
126
|
+
if (permissions.includes("*:*:*") || permissions.includes("operations:execute:global")) scopes.add("operations");
|
|
127
|
+
return [...scopes];
|
|
128
|
+
}
|
|
129
|
+
async principalForUser(userId) {
|
|
130
|
+
const user = await this.loadUser(userId);
|
|
131
|
+
if (!user) {
|
|
132
|
+
throw new Error(`Unknown user "${userId}".`);
|
|
133
|
+
}
|
|
134
|
+
const roles = await this.rolesForUser(userId);
|
|
135
|
+
const permissions = await this.permissionsForUser(userId);
|
|
136
|
+
return {
|
|
137
|
+
userId,
|
|
138
|
+
principal: {
|
|
139
|
+
id: user.id,
|
|
140
|
+
displayName: user.display_name ?? void 0,
|
|
141
|
+
roles,
|
|
142
|
+
permissions,
|
|
143
|
+
scopes: this.scopesForPrincipal(permissions),
|
|
144
|
+
metadata: parseJson(user.metadata_json, {})
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async assignRole(userId, roleKey) {
|
|
149
|
+
const role = await this.first(`SELECT id FROM roles WHERE key = ?`, [roleKey]);
|
|
150
|
+
if (!role) return;
|
|
151
|
+
await this.run(
|
|
152
|
+
`INSERT OR IGNORE INTO user_role_bindings (id, user_id, role_id, created_at)
|
|
153
|
+
VALUES (?, ?, ?, ?)`,
|
|
154
|
+
[randomUUID(), userId, role.id, isoNow()]
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
async bootstrapRolesForUser(userId, identity) {
|
|
158
|
+
await this.assignRole(userId, "member");
|
|
159
|
+
if ((await this.rolesForUser(userId)).includes("platform_admin")) return;
|
|
160
|
+
const allowlist = this.config.bootstrapAdminAllowlist;
|
|
161
|
+
const email = identity.email?.trim().toLowerCase() ?? "";
|
|
162
|
+
const providerSubject = `${identity.provider}:${identity.providerSubject}`;
|
|
163
|
+
if (allowlist.includes(email) || allowlist.includes(providerSubject)) {
|
|
164
|
+
await this.assignRole(userId, "platform_admin");
|
|
165
|
+
await this.writeAuditEvent({
|
|
166
|
+
actorType: "system",
|
|
167
|
+
actorId: null,
|
|
168
|
+
eventType: "auth.bootstrap_admin",
|
|
169
|
+
targetType: "user",
|
|
170
|
+
targetId: userId,
|
|
171
|
+
data: { matched: allowlist.includes(providerSubject) ? providerSubject : email }
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async writeAuditEvent(input) {
|
|
176
|
+
await this.run(
|
|
177
|
+
`INSERT INTO audit_events (id, actor_type, actor_id, event_type, target_type, target_id, data_json, created_at)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
179
|
+
[
|
|
180
|
+
randomUUID(),
|
|
181
|
+
input.actorType,
|
|
182
|
+
input.actorId,
|
|
183
|
+
input.eventType,
|
|
184
|
+
input.targetType,
|
|
185
|
+
input.targetId,
|
|
186
|
+
JSON.stringify(input.data ?? {}),
|
|
187
|
+
isoNow()
|
|
188
|
+
]
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
async syncUser(identity) {
|
|
192
|
+
await this.ensureInitialized();
|
|
193
|
+
const nowIso = isoNow();
|
|
194
|
+
const existingIdentity = await this.loadIdentityByProvider(identity.provider, identity.providerSubject);
|
|
195
|
+
let userId = existingIdentity?.user_id;
|
|
196
|
+
if (!userId) {
|
|
197
|
+
userId = randomUUID();
|
|
198
|
+
await this.run(
|
|
199
|
+
`INSERT INTO users (id, email, display_name, status, metadata_json, created_at, updated_at)
|
|
200
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?)`,
|
|
201
|
+
[
|
|
202
|
+
userId,
|
|
203
|
+
identity.email ?? null,
|
|
204
|
+
identity.displayName ?? null,
|
|
205
|
+
JSON.stringify({ emailVerified: identity.emailVerified ?? false, authProvider: identity.provider }),
|
|
206
|
+
nowIso,
|
|
207
|
+
nowIso
|
|
208
|
+
]
|
|
209
|
+
);
|
|
210
|
+
await this.run(
|
|
211
|
+
`INSERT INTO user_identities (id, user_id, provider, provider_subject, email, email_verified, profile_json, created_at, updated_at)
|
|
212
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
213
|
+
[
|
|
214
|
+
randomUUID(),
|
|
215
|
+
userId,
|
|
216
|
+
identity.provider,
|
|
217
|
+
identity.providerSubject,
|
|
218
|
+
identity.email ?? null,
|
|
219
|
+
identity.emailVerified ? 1 : 0,
|
|
220
|
+
JSON.stringify(identity.profile ?? {}),
|
|
221
|
+
nowIso,
|
|
222
|
+
nowIso
|
|
223
|
+
]
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
await this.run(
|
|
227
|
+
`UPDATE users
|
|
228
|
+
SET email = COALESCE(?, email),
|
|
229
|
+
display_name = COALESCE(?, display_name),
|
|
230
|
+
metadata_json = ?,
|
|
231
|
+
updated_at = ?
|
|
232
|
+
WHERE id = ?`,
|
|
233
|
+
[
|
|
234
|
+
identity.email ?? null,
|
|
235
|
+
identity.displayName ?? null,
|
|
236
|
+
JSON.stringify({ emailVerified: identity.emailVerified ?? false, authProvider: identity.provider }),
|
|
237
|
+
nowIso,
|
|
238
|
+
userId
|
|
239
|
+
]
|
|
240
|
+
);
|
|
241
|
+
await this.run(
|
|
242
|
+
`UPDATE user_identities
|
|
243
|
+
SET email = ?, email_verified = ?, profile_json = ?, updated_at = ?
|
|
244
|
+
WHERE provider = ? AND provider_subject = ?`,
|
|
245
|
+
[
|
|
246
|
+
identity.email ?? null,
|
|
247
|
+
identity.emailVerified ? 1 : 0,
|
|
248
|
+
JSON.stringify(identity.profile ?? {}),
|
|
249
|
+
nowIso,
|
|
250
|
+
identity.provider,
|
|
251
|
+
identity.providerSubject
|
|
252
|
+
]
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
await this.bootstrapRolesForUser(userId, identity);
|
|
256
|
+
await this.writeAuditEvent({
|
|
257
|
+
actorType: "service",
|
|
258
|
+
actorId: this.config.webServiceId,
|
|
259
|
+
eventType: "auth.user_synced",
|
|
260
|
+
targetType: "user",
|
|
261
|
+
targetId: userId,
|
|
262
|
+
data: { provider: identity.provider }
|
|
263
|
+
});
|
|
264
|
+
const principal = await this.principalForUser(userId);
|
|
265
|
+
const syncedIdentity = await this.loadIdentityByProvider(identity.provider, identity.providerSubject);
|
|
266
|
+
return {
|
|
267
|
+
...principal,
|
|
268
|
+
identityId: syncedIdentity?.id ?? null
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async startDeviceFlow(request) {
|
|
272
|
+
await this.ensureInitialized();
|
|
273
|
+
const current = now();
|
|
274
|
+
const expiresAt = addSeconds(current, this.config.deviceCodeTtlSeconds);
|
|
275
|
+
const deviceCode = nextOpaqueToken("device");
|
|
276
|
+
const userCode = `${Math.random().toString(36).slice(2, 6).toUpperCase()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
|
277
|
+
await this.run(
|
|
278
|
+
`INSERT INTO device_codes (id, device_code, user_code, requested_scopes_json, expires_at, interval_seconds, status, user_id, created_at, updated_at)
|
|
279
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`,
|
|
280
|
+
[
|
|
281
|
+
randomUUID(),
|
|
282
|
+
deviceCode,
|
|
283
|
+
userCode,
|
|
284
|
+
JSON.stringify(request.scopes?.length ? request.scopes : ["auth:me"]),
|
|
285
|
+
expiresAt.toISOString(),
|
|
286
|
+
this.config.deviceCodePollIntervalSeconds,
|
|
287
|
+
current.toISOString(),
|
|
288
|
+
current.toISOString()
|
|
289
|
+
]
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
deviceCode,
|
|
294
|
+
userCode,
|
|
295
|
+
verificationUri: `${this.config.baseUrl}/auth/device/approve`,
|
|
296
|
+
verificationUriComplete: `${this.config.baseUrl}/auth/device/approve?user_code=${encodeURIComponent(userCode)}`,
|
|
297
|
+
intervalSeconds: this.config.deviceCodePollIntervalSeconds,
|
|
298
|
+
expiresAt: expiresAt.toISOString(),
|
|
299
|
+
expiresInSeconds: this.config.deviceCodeTtlSeconds
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async approveDeviceFlow(request) {
|
|
303
|
+
await this.ensureInitialized();
|
|
304
|
+
const row = await this.first(`SELECT * FROM device_codes WHERE user_code = ?`, [request.userCode]);
|
|
305
|
+
if (!row || new Date(row.expires_at).getTime() <= Date.now()) {
|
|
306
|
+
throw new Error("Device code approval failed because the user code is unknown or expired.");
|
|
307
|
+
}
|
|
308
|
+
let userId = request.principalId;
|
|
309
|
+
if (!await this.loadUser(userId)) {
|
|
310
|
+
const createdAt = isoNow();
|
|
311
|
+
await this.run(
|
|
312
|
+
`INSERT INTO users (id, email, display_name, status, metadata_json, created_at, updated_at)
|
|
313
|
+
VALUES (?, NULL, ?, 'active', ?, ?, ?)`,
|
|
314
|
+
[userId, request.displayName ?? null, JSON.stringify(request.metadata ?? {}), createdAt, createdAt]
|
|
315
|
+
);
|
|
316
|
+
await this.assignRole(userId, "member");
|
|
317
|
+
}
|
|
318
|
+
await this.run(`UPDATE device_codes SET status = 'approved', user_id = ?, updated_at = ? WHERE id = ?`, [userId, isoNow(), row.id]);
|
|
319
|
+
await this.writeAuditEvent({
|
|
320
|
+
actorType: "user",
|
|
321
|
+
actorId: userId,
|
|
322
|
+
eventType: "auth.device_approved",
|
|
323
|
+
targetType: "device_code",
|
|
324
|
+
targetId: row.id
|
|
325
|
+
});
|
|
326
|
+
return { ok: true };
|
|
327
|
+
}
|
|
328
|
+
async pollDeviceFlow(request) {
|
|
329
|
+
await this.ensureInitialized();
|
|
330
|
+
const row = await this.first(`SELECT * FROM device_codes WHERE device_code = ?`, [request.deviceCode]);
|
|
331
|
+
if (!row) {
|
|
332
|
+
return { ok: false, status: "invalid", error: "Unknown device code." };
|
|
333
|
+
}
|
|
334
|
+
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
|
335
|
+
return { ok: false, status: "expired", error: "Device code expired." };
|
|
336
|
+
}
|
|
337
|
+
if (row.status === "pending" || !row.user_id) {
|
|
338
|
+
return { ok: true, status: "pending", intervalSeconds: row.interval_seconds };
|
|
339
|
+
}
|
|
340
|
+
if (row.status === "used") {
|
|
341
|
+
return { ok: false, status: "already_used", error: "Device code already used." };
|
|
342
|
+
}
|
|
343
|
+
await this.run(`UPDATE device_codes SET status = 'used', updated_at = ? WHERE id = ?`, [isoNow(), row.id]);
|
|
344
|
+
const principalRecord = await this.principalForUser(row.user_id);
|
|
345
|
+
const refreshToken = nextOpaqueToken("refresh");
|
|
346
|
+
const sessionId = randomUUID();
|
|
347
|
+
const refreshTokenHash = stableHash(refreshToken, this.config.authSecret);
|
|
348
|
+
const expiresAt = addSeconds(now(), this.config.accessTokenTtlSeconds);
|
|
349
|
+
const refreshExpiresAt = addSeconds(now(), this.config.refreshTokenTtlSeconds);
|
|
350
|
+
await this.run(
|
|
351
|
+
`INSERT INTO auth_sessions (id, user_id, session_type, refresh_token_hash, scopes_json, expires_at, revoked_at, data_json, created_at, updated_at)
|
|
352
|
+
VALUES (?, ?, 'device', ?, ?, ?, NULL, ?, ?, ?)`,
|
|
353
|
+
[
|
|
354
|
+
sessionId,
|
|
355
|
+
row.user_id,
|
|
356
|
+
refreshTokenHash,
|
|
357
|
+
row.requested_scopes_json,
|
|
358
|
+
refreshExpiresAt.toISOString(),
|
|
359
|
+
JSON.stringify({ deviceCodeId: row.id }),
|
|
360
|
+
isoNow(),
|
|
361
|
+
isoNow()
|
|
362
|
+
]
|
|
363
|
+
);
|
|
364
|
+
const requestedScopes = parseJson(row.requested_scopes_json, principalRecord.principal.scopes);
|
|
365
|
+
const accessToken = createAccessToken({
|
|
366
|
+
sub: principalRecord.principal.id,
|
|
367
|
+
displayName: principalRecord.principal.displayName,
|
|
368
|
+
scopes: requestedScopes,
|
|
369
|
+
roles: principalRecord.principal.roles,
|
|
370
|
+
permissions: principalRecord.principal.permissions,
|
|
371
|
+
metadata: principalRecord.principal.metadata,
|
|
372
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
373
|
+
exp: Math.floor(expiresAt.getTime() / 1e3),
|
|
374
|
+
iss: this.config.issuer,
|
|
375
|
+
jti: randomUUID(),
|
|
376
|
+
tokenType: "access"
|
|
377
|
+
}, this.config.authSecret);
|
|
378
|
+
return {
|
|
379
|
+
ok: true,
|
|
380
|
+
status: "approved",
|
|
381
|
+
accessToken,
|
|
382
|
+
refreshToken,
|
|
383
|
+
tokenType: "Bearer",
|
|
384
|
+
expiresAt: expiresAt.toISOString(),
|
|
385
|
+
expiresInSeconds: this.config.accessTokenTtlSeconds,
|
|
386
|
+
principal: {
|
|
387
|
+
...principalRecord.principal,
|
|
388
|
+
scopes: requestedScopes
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
async refreshAccessToken(request) {
|
|
393
|
+
await this.ensureInitialized();
|
|
394
|
+
const refreshHash = stableHash(request.refreshToken, this.config.authSecret);
|
|
395
|
+
const row = await this.first(
|
|
396
|
+
`SELECT * FROM auth_sessions WHERE refresh_token_hash = ? AND revoked_at IS NULL`,
|
|
397
|
+
[refreshHash]
|
|
398
|
+
);
|
|
399
|
+
if (!row || new Date(row.expires_at).getTime() <= Date.now()) {
|
|
400
|
+
throw new Error("Refresh token is invalid or expired.");
|
|
401
|
+
}
|
|
402
|
+
const principalRecord = await this.principalForUser(row.user_id);
|
|
403
|
+
const nextRefreshToken = nextOpaqueToken("refresh");
|
|
404
|
+
const nextRefreshHash = stableHash(nextRefreshToken, this.config.authSecret);
|
|
405
|
+
const nextRefreshExpiresAt = addSeconds(now(), this.config.refreshTokenTtlSeconds);
|
|
406
|
+
await this.run(
|
|
407
|
+
`UPDATE auth_sessions SET refresh_token_hash = ?, expires_at = ?, updated_at = ? WHERE id = ?`,
|
|
408
|
+
[nextRefreshHash, nextRefreshExpiresAt.toISOString(), isoNow(), row.id]
|
|
409
|
+
);
|
|
410
|
+
const requestedScopes = parseJson(row.scopes_json, principalRecord.principal.scopes);
|
|
411
|
+
const expiresAt = addSeconds(now(), this.config.accessTokenTtlSeconds);
|
|
412
|
+
const accessToken = createAccessToken({
|
|
413
|
+
sub: principalRecord.principal.id,
|
|
414
|
+
displayName: principalRecord.principal.displayName,
|
|
415
|
+
scopes: requestedScopes,
|
|
416
|
+
roles: principalRecord.principal.roles,
|
|
417
|
+
permissions: principalRecord.principal.permissions,
|
|
418
|
+
metadata: principalRecord.principal.metadata,
|
|
419
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
420
|
+
exp: Math.floor(expiresAt.getTime() / 1e3),
|
|
421
|
+
iss: this.config.issuer,
|
|
422
|
+
jti: randomUUID(),
|
|
423
|
+
tokenType: "access"
|
|
424
|
+
}, this.config.authSecret);
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
accessToken,
|
|
428
|
+
refreshToken: nextRefreshToken,
|
|
429
|
+
tokenType: "Bearer",
|
|
430
|
+
expiresAt: expiresAt.toISOString(),
|
|
431
|
+
expiresInSeconds: this.config.accessTokenTtlSeconds,
|
|
432
|
+
principal: {
|
|
433
|
+
...principalRecord.principal,
|
|
434
|
+
scopes: requestedScopes
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async createPersonalAccessToken(userId, input) {
|
|
439
|
+
await this.ensureInitialized();
|
|
440
|
+
const nowIso = isoNow();
|
|
441
|
+
const token = nextOpaqueToken("pat");
|
|
442
|
+
const id = randomUUID();
|
|
443
|
+
const tokenHash = stableHash(token, this.config.authSecret);
|
|
444
|
+
const prefix = token.slice(0, 12);
|
|
445
|
+
await this.run(
|
|
446
|
+
`INSERT INTO api_tokens (id, user_id, kind, name, token_prefix, token_hash, scopes_json, expires_at, last_used_at, revoked_at, metadata_json, created_at, updated_at)
|
|
447
|
+
VALUES (?, ?, 'personal_access_token', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`,
|
|
448
|
+
[
|
|
449
|
+
id,
|
|
450
|
+
userId,
|
|
451
|
+
input.name,
|
|
452
|
+
prefix,
|
|
453
|
+
tokenHash,
|
|
454
|
+
JSON.stringify(input.scopes?.length ? input.scopes : ["auth:me"]),
|
|
455
|
+
input.expiresAt ?? null,
|
|
456
|
+
JSON.stringify({}),
|
|
457
|
+
nowIso,
|
|
458
|
+
nowIso
|
|
459
|
+
]
|
|
460
|
+
);
|
|
461
|
+
await this.writeAuditEvent({
|
|
462
|
+
actorType: "user",
|
|
463
|
+
actorId: userId,
|
|
464
|
+
eventType: "auth.pat_created",
|
|
465
|
+
targetType: "api_token",
|
|
466
|
+
targetId: id,
|
|
467
|
+
data: { name: input.name }
|
|
468
|
+
});
|
|
469
|
+
return { id, token, prefix, name: input.name, expiresAt: input.expiresAt ?? null };
|
|
470
|
+
}
|
|
471
|
+
async listPersonalAccessTokens(userId) {
|
|
472
|
+
await this.ensureInitialized();
|
|
473
|
+
return this.all(
|
|
474
|
+
`SELECT id, name, token_prefix, expires_at, last_used_at, revoked_at, created_at
|
|
475
|
+
FROM api_tokens
|
|
476
|
+
WHERE user_id = ?
|
|
477
|
+
ORDER BY created_at DESC`,
|
|
478
|
+
[userId]
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
async revokePersonalAccessToken(userId, tokenId) {
|
|
482
|
+
await this.ensureInitialized();
|
|
483
|
+
await this.run(`UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?`, [isoNow(), tokenId, userId]);
|
|
484
|
+
await this.writeAuditEvent({
|
|
485
|
+
actorType: "user",
|
|
486
|
+
actorId: userId,
|
|
487
|
+
eventType: "auth.pat_revoked",
|
|
488
|
+
targetType: "api_token",
|
|
489
|
+
targetId: tokenId
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async upsertServiceCredential(input) {
|
|
493
|
+
const nowIso = isoNow();
|
|
494
|
+
const existing = await this.first(`SELECT id FROM service_credentials WHERE service_id = ?`, [input.serviceId]);
|
|
495
|
+
const secretHash = stableHash(input.secret, this.config.authSecret);
|
|
496
|
+
if (existing) {
|
|
497
|
+
await this.run(
|
|
498
|
+
`UPDATE service_credentials
|
|
499
|
+
SET name = ?, secret_hash = ?, roles_json = ?, permissions_json = ?, revoked_at = NULL, updated_at = ?
|
|
500
|
+
WHERE id = ?`,
|
|
501
|
+
[input.name, secretHash, JSON.stringify(input.roles ?? []), JSON.stringify(input.permissions ?? []), nowIso, existing.id]
|
|
502
|
+
);
|
|
503
|
+
return existing.id;
|
|
504
|
+
}
|
|
505
|
+
const id = randomUUID();
|
|
506
|
+
await this.run(
|
|
507
|
+
`INSERT INTO service_credentials (id, service_id, name, secret_hash, roles_json, permissions_json, revoked_at, last_used_at, created_at, updated_at)
|
|
508
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
|
509
|
+
[id, input.serviceId, input.name, secretHash, JSON.stringify(input.roles ?? []), JSON.stringify(input.permissions ?? []), nowIso, nowIso]
|
|
510
|
+
);
|
|
511
|
+
return id;
|
|
512
|
+
}
|
|
513
|
+
async createServiceCredential(input) {
|
|
514
|
+
await this.ensureInitialized();
|
|
515
|
+
const secret = nextOpaqueToken("svc");
|
|
516
|
+
const id = await this.upsertServiceCredential({ ...input, secret });
|
|
517
|
+
return { id, serviceId: input.serviceId, secret };
|
|
518
|
+
}
|
|
519
|
+
async rotateServiceCredential(serviceId) {
|
|
520
|
+
await this.ensureInitialized();
|
|
521
|
+
const row = await this.first(
|
|
522
|
+
`SELECT name, roles_json, permissions_json FROM service_credentials WHERE service_id = ? AND revoked_at IS NULL`,
|
|
523
|
+
[serviceId]
|
|
524
|
+
);
|
|
525
|
+
if (!row) {
|
|
526
|
+
throw new Error(`Unknown active service credential "${serviceId}".`);
|
|
527
|
+
}
|
|
528
|
+
return this.createServiceCredential({
|
|
529
|
+
serviceId,
|
|
530
|
+
name: row.name,
|
|
531
|
+
roles: parseJson(row.roles_json, []),
|
|
532
|
+
permissions: parseJson(row.permissions_json, [])
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async authenticateBearerToken(token) {
|
|
536
|
+
await this.ensureInitialized();
|
|
537
|
+
const patHash = stableHash(token, this.config.authSecret);
|
|
538
|
+
const pat = await this.first(
|
|
539
|
+
`SELECT id, user_id, name, scopes_json, expires_at, revoked_at
|
|
540
|
+
FROM api_tokens
|
|
541
|
+
WHERE token_hash = ?`,
|
|
542
|
+
[patHash]
|
|
543
|
+
);
|
|
544
|
+
if (pat && !pat.revoked_at && (!pat.expires_at || new Date(pat.expires_at).getTime() > Date.now())) {
|
|
545
|
+
await this.run(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, [isoNow(), pat.id]);
|
|
546
|
+
const principal = (await this.principalForUser(pat.user_id)).principal;
|
|
547
|
+
return {
|
|
548
|
+
principal: { ...principal, scopes: parseJson(pat.scopes_json, principal.scopes) },
|
|
549
|
+
credential: { type: "personal_access_token", id: pat.id, label: pat.name }
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const payload = verifyAccessToken(token, this.config.authSecret);
|
|
553
|
+
if (!payload) return null;
|
|
554
|
+
return {
|
|
555
|
+
principal: principalFromAccessTokenPayload(payload),
|
|
556
|
+
credential: {
|
|
557
|
+
type: payload.tokenType === "service" ? "service_token" : "access_token",
|
|
558
|
+
id: payload.jti,
|
|
559
|
+
label: payload.tokenType
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async authenticateService(serviceId, secret) {
|
|
564
|
+
await this.ensureInitialized();
|
|
565
|
+
const row = await this.first(
|
|
566
|
+
`SELECT id, name, secret_hash, roles_json, permissions_json, revoked_at
|
|
567
|
+
FROM service_credentials
|
|
568
|
+
WHERE service_id = ?`,
|
|
569
|
+
[serviceId]
|
|
570
|
+
);
|
|
571
|
+
if (!row || row.revoked_at) return null;
|
|
572
|
+
const incomingHash = stableHash(secret, this.config.authSecret);
|
|
573
|
+
if (!equalHash(row.secret_hash, incomingHash)) return null;
|
|
574
|
+
await this.run(`UPDATE service_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?`, [isoNow(), isoNow(), row.id]);
|
|
575
|
+
const roles = parseJson(row.roles_json, []);
|
|
576
|
+
const permissions = parseJson(row.permissions_json, []);
|
|
577
|
+
return {
|
|
578
|
+
principal: {
|
|
579
|
+
id: serviceId,
|
|
580
|
+
displayName: row.name,
|
|
581
|
+
roles,
|
|
582
|
+
permissions,
|
|
583
|
+
scopes: this.scopesForPrincipal(permissions),
|
|
584
|
+
metadata: { serviceId }
|
|
585
|
+
},
|
|
586
|
+
credential: { type: "service_secret", id: row.id, label: row.name }
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
async exchangeTrustedUserAssertion(claims) {
|
|
590
|
+
await this.ensureInitialized();
|
|
591
|
+
const principalRecord = await this.principalForUser(claims.userId);
|
|
592
|
+
const expiresAt = addSeconds(now(), this.config.webExchangeTtlSeconds);
|
|
593
|
+
const accessToken = createAccessToken({
|
|
594
|
+
sub: principalRecord.principal.id,
|
|
595
|
+
displayName: principalRecord.principal.displayName,
|
|
596
|
+
scopes: principalRecord.principal.scopes,
|
|
597
|
+
roles: principalRecord.principal.roles,
|
|
598
|
+
permissions: principalRecord.principal.permissions,
|
|
599
|
+
metadata: {
|
|
600
|
+
...principalRecord.principal.metadata,
|
|
601
|
+
actingSessionId: claims.sessionId,
|
|
602
|
+
identityId: claims.identityId,
|
|
603
|
+
authTime: claims.authTime
|
|
604
|
+
},
|
|
605
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
606
|
+
exp: Math.floor(expiresAt.getTime() / 1e3),
|
|
607
|
+
iss: this.config.issuer,
|
|
608
|
+
jti: randomUUID(),
|
|
609
|
+
tokenType: "access"
|
|
610
|
+
}, this.config.authSecret);
|
|
611
|
+
await this.writeAuditEvent({
|
|
612
|
+
actorType: "service",
|
|
613
|
+
actorId: this.config.webServiceId,
|
|
614
|
+
eventType: "auth.web_exchange",
|
|
615
|
+
targetType: "user",
|
|
616
|
+
targetId: claims.userId,
|
|
617
|
+
data: { sessionId: claims.sessionId }
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
accessToken,
|
|
622
|
+
tokenType: "Bearer",
|
|
623
|
+
expiresAt: expiresAt.toISOString(),
|
|
624
|
+
expiresInSeconds: this.config.webExchangeTtlSeconds,
|
|
625
|
+
principal: principalRecord.principal
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
export {
|
|
630
|
+
D1AuthStore
|
|
631
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ApiAuthProvider, ApiConfig, ApiPrincipal, DeviceCodeApproveRequest, DeviceCodePollRequest, DeviceCodePollResponse, DeviceCodeStartRequest, DeviceCodeStartResponse, TokenRefreshRequest, TokenRefreshResponse, TrustedUserAssertionClaims, UserIdentityProfileInput } from '../types.ts';
|
|
2
|
+
export declare class MemoryDeviceCodeAuthProvider implements ApiAuthProvider {
|
|
3
|
+
private readonly config;
|
|
4
|
+
readonly id = "memory";
|
|
5
|
+
private readonly devices;
|
|
6
|
+
private readonly refreshSessions;
|
|
7
|
+
constructor(config: ApiConfig);
|
|
8
|
+
startDeviceFlow(request: DeviceCodeStartRequest): Promise<DeviceCodeStartResponse>;
|
|
9
|
+
approveDeviceFlow(request: DeviceCodeApproveRequest): Promise<{
|
|
10
|
+
ok: true;
|
|
11
|
+
}>;
|
|
12
|
+
pollDeviceFlow(request: DeviceCodePollRequest): Promise<DeviceCodePollResponse>;
|
|
13
|
+
refreshAccessToken(request: TokenRefreshRequest): Promise<TokenRefreshResponse>;
|
|
14
|
+
authenticateBearerToken(token: string): Promise<{
|
|
15
|
+
principal: ApiPrincipal;
|
|
16
|
+
credential: {
|
|
17
|
+
type: "access_token";
|
|
18
|
+
id: string;
|
|
19
|
+
label: "service" | "access";
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
22
|
+
authenticateServiceCredential(_serviceId: string, _secret: string): Promise<any>;
|
|
23
|
+
createPersonalAccessToken(_userId: string, _input: {
|
|
24
|
+
name: string;
|
|
25
|
+
scopes?: string[];
|
|
26
|
+
expiresAt?: string | null;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
id: string;
|
|
29
|
+
token: string;
|
|
30
|
+
prefix: string;
|
|
31
|
+
name: string;
|
|
32
|
+
expiresAt: string | null;
|
|
33
|
+
}>;
|
|
34
|
+
listPersonalAccessTokens(): Promise<any[]>;
|
|
35
|
+
revokePersonalAccessToken(): Promise<void>;
|
|
36
|
+
syncUserIdentity(identity: UserIdentityProfileInput): Promise<{
|
|
37
|
+
userId: string;
|
|
38
|
+
identityId: string;
|
|
39
|
+
principal: {
|
|
40
|
+
id: string;
|
|
41
|
+
displayName: string;
|
|
42
|
+
scopes: string[];
|
|
43
|
+
roles: string[];
|
|
44
|
+
permissions: string[];
|
|
45
|
+
metadata: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
}>;
|
|
48
|
+
createServiceToken(_input: {
|
|
49
|
+
serviceId: string;
|
|
50
|
+
name: string;
|
|
51
|
+
roles?: string[];
|
|
52
|
+
permissions?: string[];
|
|
53
|
+
}): Promise<{
|
|
54
|
+
id: string;
|
|
55
|
+
serviceId: string;
|
|
56
|
+
secret: string;
|
|
57
|
+
}>;
|
|
58
|
+
rotateServiceToken(_serviceId: string): Promise<{
|
|
59
|
+
id: string;
|
|
60
|
+
serviceId: string;
|
|
61
|
+
secret: string;
|
|
62
|
+
}>;
|
|
63
|
+
createTrustedUserAssertion(claims: TrustedUserAssertionClaims): string;
|
|
64
|
+
verifyTrustedUserAssertion(assertion: string): TrustedUserAssertionClaims;
|
|
65
|
+
exchangeTrustedUserAssertion(claims: TrustedUserAssertionClaims): Promise<{
|
|
66
|
+
ok: true;
|
|
67
|
+
accessToken: string;
|
|
68
|
+
tokenType: "Bearer";
|
|
69
|
+
expiresAt: string;
|
|
70
|
+
expiresInSeconds: number;
|
|
71
|
+
principal: ApiPrincipal;
|
|
72
|
+
}>;
|
|
73
|
+
}
|