@ours.network/fleet 1.2.0-nightly.59 → 1.2.0-nightly.60
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 +3 -1
- package/dist/build-info.json +4 -4
- package/dist/web/auth.d.ts +36 -3
- package/dist/web/auth.js +117 -11
- package/dist/web/prefix-gateway.js +2 -1
- package/dist/web/server.js +42 -6
- package/dist/web/workspace-devices.d.ts +73 -2
- package/dist/web/workspace-devices.js +218 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,7 +175,9 @@ never enables it or requests root privileges.
|
|
|
175
175
|
Use `ours-fleet web revoke-all` to revoke every linked device and active
|
|
176
176
|
session. Only a domain-separated SHA-256 device-secret hash and bounded
|
|
177
177
|
timestamps are stored in the owner-private fleet state directory (`0700`
|
|
178
|
-
directory, `0600` atomic file).
|
|
178
|
+
directory, `0600` atomic file). App browser links use 5-minute access
|
|
179
|
+
credentials and rotating refresh credentials; see
|
|
180
|
+
[workspace onboarding](docs/workspace-onboarding.md#browser-link-credentials). The link and revoke controls use an
|
|
179
181
|
owner-private Unix socket; local processes running as the same OS user are
|
|
180
182
|
therefore inside the trust boundary.
|
|
181
183
|
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.60",
|
|
3
|
+
"buildId": "79a456ff560c",
|
|
4
|
+
"commit": "869d23c6d674d33b832c2b8ae97daaea2d5048b3",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-13T12:13:09.187Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"managed-cli.codex-reasoning-effort-v1",
|
|
9
9
|
"managed-cli.setup-v1",
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface BrowserSession {
|
|
|
9
9
|
createdAt: number;
|
|
10
10
|
lastSeenAt: number;
|
|
11
11
|
absoluteExpiresAt: number;
|
|
12
|
+
/** A workspace access credential, which cannot create further device links. */
|
|
13
|
+
rotating?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface AuthResult {
|
|
14
16
|
session: BrowserSession;
|
|
@@ -20,6 +22,8 @@ interface Ticket {
|
|
|
20
22
|
purpose: 'events' | 'conversation';
|
|
21
23
|
roleId?: string;
|
|
22
24
|
expiresAt: number;
|
|
25
|
+
/** When the credential that minted this ticket stops authorizing; a socket opened with it ends then. */
|
|
26
|
+
authorizedUntil: number;
|
|
23
27
|
}
|
|
24
28
|
export declare class WebAuth {
|
|
25
29
|
private _origin;
|
|
@@ -37,6 +41,7 @@ export declare class WebAuth {
|
|
|
37
41
|
private readonly tickets;
|
|
38
42
|
private readonly rates;
|
|
39
43
|
private readonly transports;
|
|
44
|
+
private readonly renewals;
|
|
40
45
|
private readonly sockets;
|
|
41
46
|
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
|
|
42
47
|
get bootstrapSecret(): string;
|
|
@@ -66,9 +71,22 @@ export declare class WebAuth {
|
|
|
66
71
|
expiresAt: string;
|
|
67
72
|
};
|
|
68
73
|
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
69
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
|
|
76
|
+
* device shares one session id across credentials, so the socket ends at that credential's own
|
|
77
|
+
* deadline; revocation still ends everything the device has open.
|
|
78
|
+
*/
|
|
79
|
+
bindSocket(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, socket: WebSocket): void;
|
|
70
80
|
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
71
|
-
bindTransport(
|
|
81
|
+
bindTransport(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>, close: () => void, renewal?: string): () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Keep one open transport past the credential that opened it. Only its own reader knows the
|
|
84
|
+
* name it was given, and it must authenticate with a newer credential of the same device while
|
|
85
|
+
* the transport is still open: a copied credential cannot renew after it has expired.
|
|
86
|
+
*/
|
|
87
|
+
renewTransport(request: FastifyRequest, renewal: string): {
|
|
88
|
+
authorizedUntil: number;
|
|
89
|
+
};
|
|
72
90
|
clearSessions(): void;
|
|
73
91
|
revokeAllTrustedDevices(): number;
|
|
74
92
|
workspaceIdentity(): {
|
|
@@ -83,11 +101,26 @@ export declare class WebAuth {
|
|
|
83
101
|
enrollment: string;
|
|
84
102
|
workspaceId: string;
|
|
85
103
|
label: string;
|
|
86
|
-
|
|
104
|
+
credential?: unknown;
|
|
105
|
+
}): import("./workspace-devices.js").WorkspaceCredentials | {
|
|
87
106
|
token: string;
|
|
88
107
|
device: import("./workspace-devices.js").WorkspaceDevice;
|
|
89
108
|
workspaceId: string;
|
|
90
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Refresh credentials are exchanged between the account server and this host only. A browser
|
|
112
|
+
* always states where a request comes from, so a request that does is refused: no page can be
|
|
113
|
+
* handed refresh material, whichever origin it runs on.
|
|
114
|
+
*/
|
|
115
|
+
private machineOnly;
|
|
116
|
+
refreshWorkspace(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials;
|
|
117
|
+
exchangeWorkspace(request: FastifyRequest): import("./workspace-devices.js").WorkspaceCredentials;
|
|
118
|
+
revokeWorkspaceRefresh(request: FastifyRequest, refreshHash: string): boolean;
|
|
119
|
+
/** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
|
|
120
|
+
linkWorkspaceDevice(request: FastifyRequest, refreshToken: string): import("./workspace-devices.js").WorkspaceCredentials & {
|
|
121
|
+
enrollment: string;
|
|
122
|
+
enrollmentExpiresAt: number;
|
|
123
|
+
};
|
|
91
124
|
listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
|
|
92
125
|
revokeWorkspaceDevice(id: string): void;
|
|
93
126
|
private requireWorkspaceDevices;
|
package/dist/web/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
1
|
+
import { WorkspaceDeviceAuthError, WorkspaceRefreshReuseError } from './workspace-devices.js';
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { FleetError } from '../application/errors.js';
|
|
4
4
|
import { TrustedDeviceStore } from './device-store.js';
|
|
@@ -25,6 +25,7 @@ export class WebAuth {
|
|
|
25
25
|
tickets = new Map();
|
|
26
26
|
rates = new Map();
|
|
27
27
|
transports = new Map();
|
|
28
|
+
renewals = new Map();
|
|
28
29
|
sockets = new Map();
|
|
29
30
|
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
|
|
30
31
|
this._origin = _origin;
|
|
@@ -121,7 +122,8 @@ export class WebAuth {
|
|
|
121
122
|
throw new WorkspaceDeviceAuthError('workspace bearer credential is invalid');
|
|
122
123
|
const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
|
|
123
124
|
const id = `workspace:${device.id}`;
|
|
124
|
-
|
|
125
|
+
// A transport opened with this credential ends when this credential does, whatever replaces it.
|
|
126
|
+
const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.authorizedUntil, rotating: device.rotating };
|
|
125
127
|
this.sessions.set(id, session);
|
|
126
128
|
this.sessionDevices.set(id, id);
|
|
127
129
|
if (mutation)
|
|
@@ -162,7 +164,8 @@ export class WebAuth {
|
|
|
162
164
|
const value = token();
|
|
163
165
|
const ticket = {
|
|
164
166
|
value, sessionId: session.id, purpose, roleId,
|
|
165
|
-
expiresAt: this.now() + 30_000,
|
|
167
|
+
expiresAt: Math.min(this.now() + 30_000, session.absoluteExpiresAt),
|
|
168
|
+
authorizedUntil: session.absoluteExpiresAt,
|
|
166
169
|
};
|
|
167
170
|
this.tickets.set(value, ticket);
|
|
168
171
|
return { ticket: value, expiresAt: new Date(ticket.expiresAt).toISOString() };
|
|
@@ -177,39 +180,80 @@ export class WebAuth {
|
|
|
177
180
|
const session = this.sessions.get(ticket.sessionId);
|
|
178
181
|
if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
|
|
179
182
|
throw new FleetError('unauthorized', 'browser session expired');
|
|
180
|
-
|
|
183
|
+
if (!session.id.startsWith('workspace:'))
|
|
184
|
+
return session;
|
|
185
|
+
// A workspace device's session entry is replaced by each newer credential; the ticket carries
|
|
186
|
+
// the deadline of the credential that minted it, and nothing newer extends it.
|
|
187
|
+
if (ticket.authorizedUntil <= this.now())
|
|
188
|
+
throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
|
|
189
|
+
return { ...session, absoluteExpiresAt: ticket.authorizedUntil };
|
|
181
190
|
}
|
|
182
|
-
|
|
191
|
+
/**
|
|
192
|
+
* `session` is what authenticate() or consumeTicket() returned for this connection. A workspace
|
|
193
|
+
* device shares one session id across credentials, so the socket ends at that credential's own
|
|
194
|
+
* deadline; revocation still ends everything the device has open.
|
|
195
|
+
*/
|
|
196
|
+
bindSocket(session, socket) {
|
|
197
|
+
const sessionId = session.id, deadline = sessionId.startsWith('workspace:') ? session.absoluteExpiresAt : undefined;
|
|
183
198
|
let sockets = this.sockets.get(sessionId);
|
|
184
199
|
if (!sockets) {
|
|
185
200
|
sockets = new Set();
|
|
186
201
|
this.sockets.set(sessionId, sockets);
|
|
187
202
|
}
|
|
188
203
|
sockets.add(socket);
|
|
189
|
-
const expiry = setInterval(() => { const
|
|
190
|
-
this.removeSession(sessionId);
|
|
204
|
+
const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (deadline !== undefined ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
|
|
205
|
+
this.removeSession(sessionId);
|
|
206
|
+
else if (deadline !== undefined && deadline <= this.now()) {
|
|
207
|
+
clearInterval(expiry);
|
|
208
|
+
socket.close(4401, 'authentication expired');
|
|
209
|
+
} }, 1000);
|
|
191
210
|
expiry.unref();
|
|
192
211
|
socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
|
|
193
212
|
}
|
|
194
213
|
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
195
|
-
bindTransport(
|
|
214
|
+
bindTransport(session, close, renewal) {
|
|
215
|
+
const sessionId = session.id, workspace = sessionId.startsWith('workspace:');
|
|
196
216
|
let transports = this.transports.get(sessionId);
|
|
197
217
|
if (!transports) {
|
|
198
218
|
transports = new Set();
|
|
199
219
|
this.transports.set(sessionId, transports);
|
|
200
220
|
}
|
|
201
221
|
transports.add(close);
|
|
202
|
-
const
|
|
203
|
-
|
|
222
|
+
const lease = { sessionId, deadline: session.absoluteExpiresAt };
|
|
223
|
+
if (workspace && renewal)
|
|
224
|
+
this.renewals.set(renewal, lease);
|
|
225
|
+
const expiry = setInterval(() => { const current = this.sessions.get(sessionId); if (!current || (workspace ? !this.workspaceDevices?.valid(sessionId.slice(10)) : current.absoluteExpiresAt <= this.now()))
|
|
226
|
+
this.removeSession(sessionId);
|
|
227
|
+
else if (workspace && lease.deadline <= this.now()) {
|
|
228
|
+
clearInterval(expiry);
|
|
229
|
+
transports.delete(close);
|
|
230
|
+
if (renewal)
|
|
231
|
+
this.renewals.delete(renewal);
|
|
232
|
+
close();
|
|
233
|
+
} }, 1000);
|
|
204
234
|
expiry.unref();
|
|
205
|
-
return () => { clearInterval(expiry); transports.delete(close); if (
|
|
235
|
+
return () => { clearInterval(expiry); transports.delete(close); if (renewal)
|
|
236
|
+
this.renewals.delete(renewal); if (!transports.size)
|
|
206
237
|
this.transports.delete(sessionId); };
|
|
207
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Keep one open transport past the credential that opened it. Only its own reader knows the
|
|
241
|
+
* name it was given, and it must authenticate with a newer credential of the same device while
|
|
242
|
+
* the transport is still open: a copied credential cannot renew after it has expired.
|
|
243
|
+
*/
|
|
244
|
+
renewTransport(request, renewal) {
|
|
245
|
+
const session = this.authenticate(request, true), lease = this.renewals.get(renewal);
|
|
246
|
+
if (!lease || lease.sessionId !== session.id || lease.deadline <= this.now())
|
|
247
|
+
throw new FleetError('resource_not_found', 'transport is not open');
|
|
248
|
+
lease.deadline = Math.max(lease.deadline, session.absoluteExpiresAt);
|
|
249
|
+
return { authorizedUntil: lease.deadline };
|
|
250
|
+
}
|
|
208
251
|
clearSessions() {
|
|
209
252
|
for (const transports of this.transports.values())
|
|
210
253
|
for (const close of transports)
|
|
211
254
|
close();
|
|
212
255
|
this.transports.clear();
|
|
256
|
+
this.renewals.clear();
|
|
213
257
|
for (const sockets of this.sockets.values())
|
|
214
258
|
for (const socket of sockets)
|
|
215
259
|
socket.close(4401, 'authentication revoked');
|
|
@@ -228,10 +272,69 @@ export class WebAuth {
|
|
|
228
272
|
workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
|
|
229
273
|
workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
|
|
230
274
|
enrollWorkspace(request, input) {
|
|
275
|
+
if (input.credential === 2) {
|
|
276
|
+
this.machineOnly(request);
|
|
277
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
278
|
+
return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label, true);
|
|
279
|
+
}
|
|
231
280
|
this.validateBoundary(request, true);
|
|
232
281
|
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
233
282
|
return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
|
|
234
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Refresh credentials are exchanged between the account server and this host only. A browser
|
|
286
|
+
* always states where a request comes from, so a request that does is refused: no page can be
|
|
287
|
+
* handed refresh material, whichever origin it runs on.
|
|
288
|
+
*/
|
|
289
|
+
machineOnly(request) {
|
|
290
|
+
this.validateBoundary(request, false);
|
|
291
|
+
if (request.headers.origin !== undefined || Object.keys(request.headers).some(name => name.startsWith('sec-fetch-')) || request.headers.cookie !== undefined)
|
|
292
|
+
throw new FleetError('forbidden', 'this endpoint does not accept browser requests');
|
|
293
|
+
}
|
|
294
|
+
refreshWorkspace(request, refreshToken) {
|
|
295
|
+
this.machineOnly(request);
|
|
296
|
+
this.consumeRate('workspace-refresh', 240, 60000);
|
|
297
|
+
try {
|
|
298
|
+
return this.requireWorkspaceDevices().refresh(refreshToken);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof WorkspaceRefreshReuseError)
|
|
302
|
+
for (const id of error.revoked)
|
|
303
|
+
this.removeSession(`workspace:${id}`);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
exchangeWorkspace(request) {
|
|
308
|
+
this.machineOnly(request);
|
|
309
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
310
|
+
const authorization = String(request.headers.authorization ?? '');
|
|
311
|
+
const issued = this.requireWorkspaceDevices().exchange(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
|
|
312
|
+
// Whatever the fixed bearer had open ends with it.
|
|
313
|
+
this.removeSession(`workspace:${issued.device.id}`);
|
|
314
|
+
return issued;
|
|
315
|
+
}
|
|
316
|
+
revokeWorkspaceRefresh(request, refreshHash) {
|
|
317
|
+
this.machineOnly(request);
|
|
318
|
+
this.consumeRate('workspace-revoke', 120, 60000);
|
|
319
|
+
const id = this.requireWorkspaceDevices().revokeByRefreshHash(refreshHash);
|
|
320
|
+
if (id)
|
|
321
|
+
this.removeSession(`workspace:${id}`);
|
|
322
|
+
return Boolean(id);
|
|
323
|
+
}
|
|
324
|
+
/** A link code for another browser, for the issuing link's own current refresh credential, which it rotates. */
|
|
325
|
+
linkWorkspaceDevice(request, refreshToken) {
|
|
326
|
+
this.machineOnly(request);
|
|
327
|
+
this.consumeRate('workspace-link', 10, 60000);
|
|
328
|
+
try {
|
|
329
|
+
return this.requireWorkspaceDevices().link(refreshToken);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error instanceof WorkspaceRefreshReuseError)
|
|
333
|
+
for (const id of error.revoked)
|
|
334
|
+
this.removeSession(`workspace:${id}`);
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
235
338
|
listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
|
|
236
339
|
revokeWorkspaceDevice(id) {
|
|
237
340
|
this.requireWorkspaceDevices().revoke(id);
|
|
@@ -257,6 +360,9 @@ export class WebAuth {
|
|
|
257
360
|
for (const close of this.transports.get(id) ?? [])
|
|
258
361
|
close();
|
|
259
362
|
this.transports.delete(id);
|
|
363
|
+
for (const [renewal, lease] of this.renewals)
|
|
364
|
+
if (lease.sessionId === id)
|
|
365
|
+
this.renewals.delete(renewal);
|
|
260
366
|
this.sessions.delete(id);
|
|
261
367
|
this.sessionDevices.delete(id);
|
|
262
368
|
for (const [ticket, value] of this.tickets)
|
|
@@ -106,7 +106,8 @@ export function createPrefixGateway(options) {
|
|
|
106
106
|
const track = (authHeaders, session, close) => {
|
|
107
107
|
if (!session)
|
|
108
108
|
return () => { };
|
|
109
|
-
|
|
109
|
+
// The session authenticated for this request, with its own deadline, not whatever is current later.
|
|
110
|
+
const unbind = auth.bindTransport(session, close);
|
|
110
111
|
const timer = setInterval(() => { try {
|
|
111
112
|
auth.authenticate({ headers: authHeaders });
|
|
112
113
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -134,10 +134,41 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
134
134
|
throw new FleetError('invalid_request', 'device enrollment body required');
|
|
135
135
|
return auth.enrollWorkspace(request, input);
|
|
136
136
|
});
|
|
137
|
-
|
|
137
|
+
// Refresh credentials travel between the account server and this host; these routes refuse browsers.
|
|
138
|
+
const refreshInput = (request, name) => {
|
|
139
|
+
const value = request.body?.[name];
|
|
140
|
+
if (typeof value !== 'string')
|
|
141
|
+
throw new FleetError('invalid_request', `${name} is required`);
|
|
142
|
+
return value;
|
|
143
|
+
};
|
|
144
|
+
app.post('/api/v1/devices/refresh', async (request, reply) => {
|
|
145
|
+
reply.header('Cache-Control', 'no-store');
|
|
146
|
+
return auth.refreshWorkspace(request, refreshInput(request, 'refreshToken'));
|
|
147
|
+
});
|
|
148
|
+
app.post('/api/v1/devices/exchange', async (request, reply) => {
|
|
149
|
+
reply.header('Cache-Control', 'no-store');
|
|
150
|
+
return auth.exchangeWorkspace(request);
|
|
151
|
+
});
|
|
152
|
+
app.post('/api/v1/devices/revoke', async (request) => ({ revoked: auth.revokeWorkspaceRefresh(request, refreshInput(request, 'refreshHash')) }));
|
|
153
|
+
app.post('/api/v1/devices/link', async (request, reply) => {
|
|
154
|
+
const proof = request.body?.refreshToken;
|
|
155
|
+
if (proof !== undefined) {
|
|
156
|
+
reply.header('Cache-Control', 'no-store');
|
|
157
|
+
const { enrollment, enrollmentExpiresAt, ...credentials } = auth.linkWorkspaceDevice(request, refreshInput(request, 'refreshToken'));
|
|
158
|
+
return { ...credentials, code: { version: 1, origin: auth.origin, enrollment, workspaceId: credentials.workspaceId, expiresAt: enrollmentExpiresAt } };
|
|
159
|
+
}
|
|
138
160
|
const session = auth.authenticate(request, true);
|
|
161
|
+
// A short-lived access credential must not be able to create a lasting one.
|
|
162
|
+
if (session.rotating)
|
|
163
|
+
throw new FleetError('forbidden', 'linking another device requires this link\'s refresh credential');
|
|
139
164
|
return { version: 1, origin: auth.origin, ...auth.workspaceEnrollment(session.id) };
|
|
140
165
|
});
|
|
166
|
+
app.get('/api/v1/devices/current', async (request) => {
|
|
167
|
+
const session = auth.authenticate(request);
|
|
168
|
+
if (!session.id.startsWith('workspace:'))
|
|
169
|
+
throw new FleetError('resource_not_found', 'no workspace device');
|
|
170
|
+
return { deviceId: session.id.slice(10), authorizedUntil: session.absoluteExpiresAt };
|
|
171
|
+
});
|
|
141
172
|
app.get('/api/v1/devices', async (request) => { auth.authenticate(request); return { devices: auth.listWorkspaceDevices() }; });
|
|
142
173
|
app.delete('/api/v1/devices/:id', async (request) => { auth.authenticate(request, true); auth.revokeWorkspaceDevice(request.params.id); return { revoked: true }; });
|
|
143
174
|
app.get('/api/v1/meta', async (request) => {
|
|
@@ -1003,7 +1034,9 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1003
1034
|
const write = (value) => { if (!ended && !reply.raw.write(JSON.stringify(value) + '\n'))
|
|
1004
1035
|
close(); };
|
|
1005
1036
|
reply.raw.once('close', close);
|
|
1006
|
-
|
|
1037
|
+
// A voice session outlives one access credential; its page renews this stream by name.
|
|
1038
|
+
const renewal = browser.rotating ? randomBytes(32).toString('base64url') : undefined;
|
|
1039
|
+
unbind = auth.bindTransport(browser, close, renewal);
|
|
1007
1040
|
try {
|
|
1008
1041
|
follow = await control.followConversation({
|
|
1009
1042
|
after: page.nextCursor,
|
|
@@ -1012,7 +1045,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1012
1045
|
close();
|
|
1013
1046
|
}
|
|
1014
1047
|
else
|
|
1015
|
-
write({ ready: true }); },
|
|
1048
|
+
write({ ready: true, ...(renewal ? { renewal } : {}) }); },
|
|
1016
1049
|
onEvent: event => { if (event.sessionGeneration !== generation) {
|
|
1017
1050
|
write({ error: 'Agent session changed. Start live voice again.' });
|
|
1018
1051
|
close();
|
|
@@ -1021,9 +1054,11 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1021
1054
|
});
|
|
1022
1055
|
if (ended)
|
|
1023
1056
|
follow.close();
|
|
1057
|
+
// A renewable stream is ended by its own deadline and by revocation, not by the first credential's expiry.
|
|
1024
1058
|
else
|
|
1025
1059
|
heartbeat = setInterval(() => { try {
|
|
1026
|
-
|
|
1060
|
+
if (!renewal)
|
|
1061
|
+
auth.authenticate(request);
|
|
1027
1062
|
write({ heartbeat: true });
|
|
1028
1063
|
}
|
|
1029
1064
|
catch {
|
|
@@ -1035,6 +1070,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1035
1070
|
close();
|
|
1036
1071
|
}
|
|
1037
1072
|
});
|
|
1073
|
+
app.post('/api/v1/transports/:id/renew', async (request) => auth.renewTransport(request, request.params.id));
|
|
1038
1074
|
app.post('/api/v1/ws-tickets', async (request) => {
|
|
1039
1075
|
const body = request.body;
|
|
1040
1076
|
if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
|
|
@@ -1069,7 +1105,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1069
1105
|
requireSubprotocol(request, 'ours-fleet-events.v1');
|
|
1070
1106
|
authorizeSocket(socket, request, async (hello) => {
|
|
1071
1107
|
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'events');
|
|
1072
|
-
auth.bindSocket(session
|
|
1108
|
+
auth.bindSocket(session, socket);
|
|
1073
1109
|
const detach = events.attach(socket, typeof hello.lastEventId === 'string' ? hello.lastEventId : undefined);
|
|
1074
1110
|
socket.on('close', detach);
|
|
1075
1111
|
socket.send(JSON.stringify({ kind: 'ready', at: new Date().toISOString() }));
|
|
@@ -1079,7 +1115,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
1079
1115
|
requireSubprotocol(request, 'ours-fleet-conversation.v1');
|
|
1080
1116
|
authorizeSocket(socket, request, async (hello) => {
|
|
1081
1117
|
const session = auth.consumeTicket(request, String(hello.ticket ?? ''), 'conversation', request.params.id);
|
|
1082
|
-
auth.bindSocket(session
|
|
1118
|
+
auth.bindSocket(session, socket);
|
|
1083
1119
|
const control = await services.session(request.params.id);
|
|
1084
1120
|
if (!control.followConversation)
|
|
1085
1121
|
throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
|
|
@@ -3,6 +3,21 @@ import { FleetError } from '../application/errors.js';
|
|
|
3
3
|
export declare class WorkspaceDeviceAuthError extends FleetError {
|
|
4
4
|
constructor(message?: string);
|
|
5
5
|
}
|
|
6
|
+
/** A spent refresh credential came back after its replacement was in use: the link is revoked, whoever presented it. */
|
|
7
|
+
export declare class WorkspaceRefreshReuseError extends WorkspaceDeviceAuthError {
|
|
8
|
+
readonly revoked: string[];
|
|
9
|
+
constructor(revoked: string[]);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Credential lifetimes of an App browser link. Rotation is not expiry: every refresh replaces the
|
|
13
|
+
* refresh credential, each one lapses when unused for REFRESH_IDLE_TTL, and no rotation moves a
|
|
14
|
+
* link past the FAMILY_TTL fixed when it was created.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ACCESS_TTL: number;
|
|
17
|
+
export declare const REFRESH_IDLE_TTL: number;
|
|
18
|
+
export declare const FAMILY_TTL: number;
|
|
19
|
+
/** How long after this store first runs rotating credentials the earlier fixed bearer is still accepted. */
|
|
20
|
+
export declare const LEGACY_BEARER_WINDOW: number;
|
|
6
21
|
export interface WorkspaceDevice {
|
|
7
22
|
id: string;
|
|
8
23
|
label: string;
|
|
@@ -11,22 +26,78 @@ export interface WorkspaceDevice {
|
|
|
11
26
|
expiresAt: number;
|
|
12
27
|
revokedAt: number | null;
|
|
13
28
|
}
|
|
29
|
+
export interface WorkspaceCredentials {
|
|
30
|
+
device: WorkspaceDevice;
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
accessToken: string;
|
|
33
|
+
accessExpiresAt: number;
|
|
34
|
+
refreshToken: string;
|
|
35
|
+
refreshExpiresAt: number;
|
|
36
|
+
familyExpiresAt: number;
|
|
37
|
+
}
|
|
38
|
+
/** An authenticated device and the moment the presented credential stops authorizing anything. */
|
|
39
|
+
export type AuthenticatedWorkspaceDevice = WorkspaceDevice & {
|
|
40
|
+
authorizedUntil: number;
|
|
41
|
+
rotating: boolean;
|
|
42
|
+
};
|
|
14
43
|
export declare class WorkspaceDeviceStore {
|
|
15
44
|
private readonly now;
|
|
16
45
|
private readonly db;
|
|
17
46
|
readonly workspaceId: string;
|
|
47
|
+
/** After this moment a fixed bearer no longer authenticates and no new one is issued. */
|
|
48
|
+
readonly legacySunsetAt: number;
|
|
49
|
+
private readonly rotationKey;
|
|
18
50
|
constructor(dir?: string, now?: () => number);
|
|
51
|
+
private transaction;
|
|
19
52
|
mint(issuer?: string | null): {
|
|
20
53
|
enrollment: string;
|
|
21
54
|
workspaceId: string;
|
|
22
55
|
expiresAt: number;
|
|
23
56
|
};
|
|
24
|
-
|
|
57
|
+
/** Link a browser with rotating credentials. */
|
|
58
|
+
enroll(enrollment: string, workspaceId: string, label: string, rotating: true): WorkspaceCredentials;
|
|
59
|
+
/** The fixed bearer of earlier App versions; refused once the legacy window has closed. */
|
|
60
|
+
enroll(enrollment: string, workspaceId: string, label: string, rotating?: false): {
|
|
25
61
|
token: string;
|
|
26
62
|
device: WorkspaceDevice;
|
|
27
63
|
workspaceId: string;
|
|
28
64
|
};
|
|
29
|
-
|
|
65
|
+
/** Inside a transaction: an access credential beside the given current refresh credential. */
|
|
66
|
+
private grant;
|
|
67
|
+
private live;
|
|
68
|
+
private device;
|
|
69
|
+
/**
|
|
70
|
+
* Replace a refresh credential. It has exactly one successor, derived on this host. Until that
|
|
71
|
+
* successor is seen in use (presented itself, or an access credential issued with it
|
|
72
|
+
* authenticates), the spent credential is answered again with the same successor: a lost
|
|
73
|
+
* answer, a closed tab or a restart converge on one value and extend nothing. Once the successor
|
|
74
|
+
* is in use, the spent credential is a replay and ends the link and the links it issued.
|
|
75
|
+
*/
|
|
76
|
+
refresh(refreshToken: string): WorkspaceCredentials;
|
|
77
|
+
/**
|
|
78
|
+
* A link code for another browser, issued by this link. It is a lasting grant, so it takes the
|
|
79
|
+
* link's current, unspent refresh credential and rotates it in the same step, with the successor
|
|
80
|
+
* counted as in use at once: whoever else holds the spent credential is a replay on their next
|
|
81
|
+
* refresh, which also ends the link made from this code. An answer lost here is not retried.
|
|
82
|
+
*/
|
|
83
|
+
link(refreshToken: string): WorkspaceCredentials & {
|
|
84
|
+
enrollment: string;
|
|
85
|
+
enrollmentExpiresAt: number;
|
|
86
|
+
};
|
|
87
|
+
/** Inside a transaction. Returns new credentials, or the devices ended by a replay. `strict` allows no repeat and confirms the successor. */
|
|
88
|
+
private rotate;
|
|
89
|
+
/**
|
|
90
|
+
* One-time move of a fixed bearer to rotating credentials. The device keeps its identity, the
|
|
91
|
+
* bearer stops working in the same transaction, and the link ends no later than it would have.
|
|
92
|
+
*/
|
|
93
|
+
exchange(token: string): WorkspaceCredentials;
|
|
94
|
+
private legacy;
|
|
95
|
+
/** The device behind an access credential, or behind a fixed bearer while those are still accepted. */
|
|
96
|
+
authenticate(token: string): AuthenticatedWorkspaceDevice;
|
|
97
|
+
/** End the link a refresh credential belongs to, named by its SHA-256: a hash can end a link, never use it. */
|
|
98
|
+
revokeByRefreshHash(refreshHash: string): string | undefined;
|
|
99
|
+
/** Inside a transaction. With cascade, links issued from this one end too. Returns every device ended. */
|
|
100
|
+
private revokeRows;
|
|
30
101
|
valid(id: string): boolean;
|
|
31
102
|
list(): WorkspaceDevice[];
|
|
32
103
|
revoke(id: string): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { mkdirSync, chmodSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { stateRoot } from '../paths.js';
|
|
@@ -10,10 +10,33 @@ const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
|
10
10
|
export class WorkspaceDeviceAuthError extends FleetError {
|
|
11
11
|
constructor(message = 'workspace device is missing, expired, or revoked') { super('unauthorized', message); }
|
|
12
12
|
}
|
|
13
|
+
/** A spent refresh credential came back after its replacement was in use: the link is revoked, whoever presented it. */
|
|
14
|
+
export class WorkspaceRefreshReuseError extends WorkspaceDeviceAuthError {
|
|
15
|
+
revoked;
|
|
16
|
+
constructor(revoked) {
|
|
17
|
+
super('workspace refresh credential was already replaced; this device link is revoked');
|
|
18
|
+
this.revoked = revoked;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Credential lifetimes of an App browser link. Rotation is not expiry: every refresh replaces the
|
|
23
|
+
* refresh credential, each one lapses when unused for REFRESH_IDLE_TTL, and no rotation moves a
|
|
24
|
+
* link past the FAMILY_TTL fixed when it was created.
|
|
25
|
+
*/
|
|
26
|
+
export const ACCESS_TTL = 5 * 60_000;
|
|
27
|
+
export const REFRESH_IDLE_TTL = 7 * 86_400_000;
|
|
28
|
+
export const FAMILY_TTL = 30 * 86_400_000;
|
|
29
|
+
/** How long after this store first runs rotating credentials the earlier fixed bearer is still accepted. */
|
|
30
|
+
export const LEGACY_BEARER_WINDOW = 30 * 86_400_000;
|
|
31
|
+
const ACCESS_PREFIX = 'owa.', REFRESH_PREFIX = 'owr.';
|
|
32
|
+
const refreshShaped = (value) => typeof value === 'string' && value.startsWith(REFRESH_PREFIX) && value.length <= 128;
|
|
13
33
|
export class WorkspaceDeviceStore {
|
|
14
34
|
now;
|
|
15
35
|
db;
|
|
16
36
|
workspaceId;
|
|
37
|
+
/** After this moment a fixed bearer no longer authenticates and no new one is issued. */
|
|
38
|
+
legacySunsetAt;
|
|
39
|
+
rotationKey;
|
|
17
40
|
constructor(dir = join(stateRoot(), 'web'), now = Date.now) {
|
|
18
41
|
this.now = now;
|
|
19
42
|
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
|
|
@@ -26,76 +49,229 @@ export class WorkspaceDeviceStore {
|
|
|
26
49
|
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
27
50
|
CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY,hash TEXT NOT NULL,label TEXT NOT NULL,createdAt INTEGER NOT NULL,lastUsedAt INTEGER NOT NULL,expiresAt INTEGER NOT NULL,revokedAt INTEGER);
|
|
28
51
|
CREATE TABLE IF NOT EXISTS enrollments(hash TEXT PRIMARY KEY,expiresAt INTEGER NOT NULL,issuer TEXT);`);
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
// A device row is one browser link. A rotating link keeps no bearer hash: its credentials are the rows below.
|
|
53
|
+
const columns = this.db.prepare('PRAGMA table_info(devices)').all().map(column => column.name);
|
|
54
|
+
if (!columns.includes('familyExpiresAt'))
|
|
55
|
+
this.db.exec('ALTER TABLE devices ADD COLUMN familyExpiresAt INTEGER');
|
|
56
|
+
if (!columns.includes('issuer'))
|
|
57
|
+
this.db.exec('ALTER TABLE devices ADD COLUMN issuer TEXT');
|
|
58
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS refresh_tokens(hash TEXT PRIMARY KEY,deviceId TEXT NOT NULL,issuedAt INTEGER NOT NULL,expiresAt INTEGER NOT NULL,consumedAt INTEGER,confirmedAt INTEGER,parent TEXT);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS refresh_tokens_device ON refresh_tokens(deviceId);
|
|
60
|
+
CREATE UNIQUE INDEX IF NOT EXISTS refresh_tokens_parent ON refresh_tokens(parent) WHERE parent IS NOT NULL;
|
|
61
|
+
CREATE TABLE IF NOT EXISTS access_tokens(hash TEXT PRIMARY KEY,deviceId TEXT NOT NULL,expiresAt INTEGER NOT NULL,refresh TEXT NOT NULL);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS access_tokens_device ON access_tokens(deviceId);`);
|
|
63
|
+
const metadata = (key, initial) => { this.db.prepare('INSERT OR IGNORE INTO metadata VALUES(?,?)').run(key, initial); return String(this.db.prepare('SELECT value FROM metadata WHERE key=?').get(key).value); };
|
|
64
|
+
this.legacySunsetAt = Number(metadata('legacySunsetAt', String(this.now() + LEGACY_BEARER_WINDOW)));
|
|
65
|
+
// Derives each refresh credential's one successor from the credential itself; it never leaves
|
|
66
|
+
// this host and is useless without a current credential. It lives and dies with this
|
|
67
|
+
// database: a lost or replaced file ends every link, which then needs a new link code.
|
|
68
|
+
this.rotationKey = Buffer.from(metadata('rotationKey', opaque()), 'base64url');
|
|
69
|
+
this.workspaceId = metadata('workspaceId', opaque());
|
|
31
70
|
}
|
|
32
|
-
|
|
33
|
-
const now = this.now(), enrollment = opaque(), expiresAt = now + 5 * 60000;
|
|
71
|
+
transaction(run) {
|
|
34
72
|
this.db.exec('BEGIN IMMEDIATE');
|
|
35
73
|
try {
|
|
36
|
-
|
|
37
|
-
const count = Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n);
|
|
38
|
-
if (count >= 32)
|
|
39
|
-
throw new FleetError('rate_limited', 'too many pending device links');
|
|
40
|
-
this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), expiresAt, issuer);
|
|
74
|
+
const result = run();
|
|
41
75
|
this.db.exec('COMMIT');
|
|
42
|
-
return
|
|
76
|
+
return result;
|
|
43
77
|
}
|
|
44
78
|
catch (error) {
|
|
45
79
|
this.db.exec('ROLLBACK');
|
|
46
80
|
throw error;
|
|
47
81
|
}
|
|
48
82
|
}
|
|
49
|
-
|
|
83
|
+
mint(issuer = null) {
|
|
84
|
+
const now = this.now(), enrollment = opaque(), expiresAt = now + 5 * 60000;
|
|
85
|
+
return this.transaction(() => {
|
|
86
|
+
this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
|
|
87
|
+
const count = Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n);
|
|
88
|
+
if (count >= 32)
|
|
89
|
+
throw new FleetError('rate_limited', 'too many pending device links');
|
|
90
|
+
this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), expiresAt, issuer);
|
|
91
|
+
return { enrollment, workspaceId: this.workspaceId, expiresAt };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
enroll(enrollment, workspaceId, label, rotating = false) {
|
|
50
95
|
if (workspaceId !== this.workspaceId || typeof enrollment !== 'string' || !/^[\w-]{43}$/.test(enrollment))
|
|
51
96
|
throw new FleetError('unauthorized', 'invalid device enrollment');
|
|
52
97
|
if (typeof label !== 'string' || !label.trim() || label.length > 100 || /[\x00-\x1f\x7f]/.test(label))
|
|
53
98
|
throw new FleetError('invalid_request', 'device name must contain 1–100 printable characters');
|
|
54
99
|
const now = this.now(), id = opaque(), token = `${id}.${opaque()}`;
|
|
55
|
-
this.
|
|
56
|
-
|
|
100
|
+
if (!rotating && now >= this.legacySunsetAt)
|
|
101
|
+
throw new FleetError('invalid_request', 'this workspace no longer issues fixed device credentials; update the App');
|
|
102
|
+
return this.transaction(() => {
|
|
57
103
|
const code = this.db.prepare('DELETE FROM enrollments WHERE hash=? AND expiresAt>? RETURNING issuer').get(hash(enrollment), now);
|
|
58
104
|
if (!code)
|
|
59
105
|
throw new FleetError('unauthorized', 'device enrollment is expired or already used');
|
|
60
|
-
if (code.issuer && !this.
|
|
106
|
+
if (code.issuer && !this.live(String(code.issuer), now))
|
|
61
107
|
throw new FleetError('unauthorized', 'issuing device has been revoked');
|
|
62
108
|
const count = Number(this.db.prepare('SELECT count(*) AS n FROM devices WHERE revokedAt IS NULL AND expiresAt>?').get(now).n);
|
|
63
109
|
if (count >= 128)
|
|
64
110
|
throw new FleetError('rate_limited', 'authorized device limit reached');
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
111
|
+
const issuer = code.issuer === null ? null : String(code.issuer);
|
|
112
|
+
if (!rotating) {
|
|
113
|
+
const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: now + 90 * 86400000, revokedAt: null };
|
|
114
|
+
this.db.prepare('INSERT INTO devices(id,hash,label,createdAt,lastUsedAt,expiresAt,revokedAt,issuer) VALUES(?,?,?,?,?,?,NULL,?)').run(id, hash(token), device.label, now, now, device.expiresAt, issuer);
|
|
115
|
+
return { token, device, workspaceId };
|
|
116
|
+
}
|
|
117
|
+
const familyExpiresAt = now + FAMILY_TTL, refreshToken = REFRESH_PREFIX + opaque(), refreshExpiresAt = Math.min(now + REFRESH_IDLE_TTL, familyExpiresAt);
|
|
118
|
+
const device = { id, label: label.trim(), createdAt: now, lastUsedAt: now, expiresAt: refreshExpiresAt, revokedAt: null };
|
|
119
|
+
this.db.prepare('INSERT INTO devices(id,hash,label,createdAt,lastUsedAt,expiresAt,revokedAt,familyExpiresAt,issuer) VALUES(?,?,?,?,?,?,NULL,?,?)').run(id, '', device.label, now, now, refreshExpiresAt, familyExpiresAt, issuer);
|
|
120
|
+
this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,NULL,NULL)').run(hash(refreshToken), id, now, refreshExpiresAt);
|
|
121
|
+
return this.grant(device, familyExpiresAt, refreshToken, refreshExpiresAt, now);
|
|
122
|
+
});
|
|
74
123
|
}
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
/** Inside a transaction: an access credential beside the given current refresh credential. */
|
|
125
|
+
grant(device, familyExpiresAt, refreshToken, refreshExpiresAt, now) {
|
|
126
|
+
const accessToken = ACCESS_PREFIX + opaque(), accessExpiresAt = Math.min(now + ACCESS_TTL, familyExpiresAt);
|
|
127
|
+
this.db.prepare('DELETE FROM access_tokens WHERE expiresAt<=?').run(now);
|
|
128
|
+
this.db.prepare('DELETE FROM refresh_tokens WHERE deviceId IN (SELECT id FROM devices WHERE revokedAt IS NOT NULL OR expiresAt<=?)').run(now);
|
|
129
|
+
this.db.prepare('INSERT INTO access_tokens VALUES(?,?,?,?)').run(hash(accessToken), device.id, accessExpiresAt, hash(refreshToken));
|
|
130
|
+
return { device, workspaceId: this.workspaceId, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, familyExpiresAt };
|
|
131
|
+
}
|
|
132
|
+
live(id, now) {
|
|
77
133
|
const row = this.db.prepare('SELECT * FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, now);
|
|
78
|
-
|
|
79
|
-
|
|
134
|
+
if (!row || (row.familyExpiresAt === null && now >= this.legacySunsetAt))
|
|
135
|
+
return undefined;
|
|
136
|
+
return { id: String(row.id), hash: String(row.hash), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: null, familyExpiresAt: row.familyExpiresAt === null ? null : Number(row.familyExpiresAt) };
|
|
137
|
+
}
|
|
138
|
+
device(row, changes = {}) {
|
|
139
|
+
return { id: row.id, label: row.label, createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, expiresAt: row.expiresAt, revokedAt: null, ...changes };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Replace a refresh credential. It has exactly one successor, derived on this host. Until that
|
|
143
|
+
* successor is seen in use (presented itself, or an access credential issued with it
|
|
144
|
+
* authenticates), the spent credential is answered again with the same successor: a lost
|
|
145
|
+
* answer, a closed tab or a restart converge on one value and extend nothing. Once the successor
|
|
146
|
+
* is in use, the spent credential is a replay and ends the link and the links it issued.
|
|
147
|
+
*/
|
|
148
|
+
refresh(refreshToken) {
|
|
149
|
+
const outcome = this.transaction(() => this.rotate(refreshToken, false));
|
|
150
|
+
if (Array.isArray(outcome))
|
|
151
|
+
throw new WorkspaceRefreshReuseError(outcome);
|
|
152
|
+
return outcome;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A link code for another browser, issued by this link. It is a lasting grant, so it takes the
|
|
156
|
+
* link's current, unspent refresh credential and rotates it in the same step, with the successor
|
|
157
|
+
* counted as in use at once: whoever else holds the spent credential is a replay on their next
|
|
158
|
+
* refresh, which also ends the link made from this code. An answer lost here is not retried.
|
|
159
|
+
*/
|
|
160
|
+
link(refreshToken) {
|
|
161
|
+
const now = this.now(), enrollment = opaque(), enrollmentExpiresAt = now + 5 * 60000;
|
|
162
|
+
const outcome = this.transaction(() => {
|
|
163
|
+
const rotated = this.rotate(refreshToken, true);
|
|
164
|
+
if (Array.isArray(rotated))
|
|
165
|
+
return rotated;
|
|
166
|
+
this.db.prepare('DELETE FROM enrollments WHERE expiresAt<=?').run(now);
|
|
167
|
+
if (Number(this.db.prepare('SELECT count(*) AS n FROM enrollments').get().n) >= 32)
|
|
168
|
+
throw new FleetError('rate_limited', 'too many pending device links');
|
|
169
|
+
this.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(hash(enrollment), enrollmentExpiresAt, rotated.device.id);
|
|
170
|
+
return { ...rotated, enrollment, enrollmentExpiresAt };
|
|
171
|
+
});
|
|
172
|
+
if (Array.isArray(outcome))
|
|
173
|
+
throw new WorkspaceRefreshReuseError(outcome);
|
|
174
|
+
return outcome;
|
|
175
|
+
}
|
|
176
|
+
/** Inside a transaction. Returns new credentials, or the devices ended by a replay. `strict` allows no repeat and confirms the successor. */
|
|
177
|
+
rotate(refreshToken, strict) {
|
|
178
|
+
if (!refreshShaped(refreshToken))
|
|
80
179
|
throw new WorkspaceDeviceAuthError();
|
|
81
|
-
this.
|
|
82
|
-
|
|
180
|
+
const now = this.now(), key = hash(refreshToken);
|
|
181
|
+
const row = this.db.prepare('SELECT * FROM refresh_tokens WHERE hash=?').get(key);
|
|
182
|
+
const device = row && this.live(String(row.deviceId), now);
|
|
183
|
+
if (!row || !device || device.familyExpiresAt === null)
|
|
184
|
+
throw new WorkspaceDeviceAuthError();
|
|
185
|
+
const next = REFRESH_PREFIX + createHmac('sha256', this.rotationKey).update(`ours.fleet.workspace-refresh.v1\0${device.id}\0${refreshToken}`).digest('base64url');
|
|
186
|
+
if (row.consumedAt === null) {
|
|
187
|
+
if (Number(row.expiresAt) <= now)
|
|
188
|
+
throw new WorkspaceDeviceAuthError();
|
|
189
|
+
const expiresAt = Math.min(now + REFRESH_IDLE_TTL, device.familyExpiresAt);
|
|
190
|
+
this.db.prepare('UPDATE refresh_tokens SET consumedAt=?,confirmedAt=coalesce(confirmedAt,?) WHERE hash=?').run(now, now, key);
|
|
191
|
+
this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,?,?)').run(hash(next), device.id, now, expiresAt, strict ? now : null, key);
|
|
192
|
+
// The link itself lapses with its newest refresh credential.
|
|
193
|
+
this.db.prepare('UPDATE devices SET expiresAt=?,lastUsedAt=? WHERE id=?').run(expiresAt, now, device.id);
|
|
194
|
+
return this.grant(this.device(device, { expiresAt, lastUsedAt: now }), device.familyExpiresAt, next, expiresAt, now);
|
|
195
|
+
}
|
|
196
|
+
const successor = this.db.prepare('SELECT * FROM refresh_tokens WHERE parent=?').get(key);
|
|
197
|
+
if (strict || !successor || successor.confirmedAt !== null || successor.consumedAt !== null)
|
|
198
|
+
return this.revokeRows(device.id, now, true);
|
|
199
|
+
if (Number(successor.expiresAt) <= now)
|
|
200
|
+
throw new WorkspaceDeviceAuthError();
|
|
201
|
+
return this.grant(this.device(device), device.familyExpiresAt, next, Number(successor.expiresAt), now);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* One-time move of a fixed bearer to rotating credentials. The device keeps its identity, the
|
|
205
|
+
* bearer stops working in the same transaction, and the link ends no later than it would have.
|
|
206
|
+
*/
|
|
207
|
+
exchange(token) {
|
|
208
|
+
const now = this.now();
|
|
209
|
+
return this.transaction(() => {
|
|
210
|
+
const legacy = this.legacy(token, now);
|
|
211
|
+
const familyExpiresAt = Math.min(legacy.expiresAt, now + FAMILY_TTL), refreshToken = REFRESH_PREFIX + opaque(), refreshExpiresAt = Math.min(now + REFRESH_IDLE_TTL, familyExpiresAt);
|
|
212
|
+
this.db.prepare('UPDATE devices SET hash=?,familyExpiresAt=?,expiresAt=?,lastUsedAt=? WHERE id=?').run('', familyExpiresAt, refreshExpiresAt, now, legacy.id);
|
|
213
|
+
this.db.prepare('INSERT INTO refresh_tokens VALUES(?,?,?,?,NULL,NULL,NULL)').run(hash(refreshToken), legacy.id, now, refreshExpiresAt);
|
|
214
|
+
return this.grant({ ...legacy, expiresAt: refreshExpiresAt, lastUsedAt: now }, familyExpiresAt, refreshToken, refreshExpiresAt, now);
|
|
215
|
+
});
|
|
83
216
|
}
|
|
84
|
-
|
|
217
|
+
legacy(token, now) {
|
|
218
|
+
const row = this.live(typeof token === 'string' ? token.split('.')[0] : '', now);
|
|
219
|
+
const supplied = Buffer.from(hash(typeof token === 'string' ? token : '')), expected = Buffer.from(row?.hash || '0'.repeat(64));
|
|
220
|
+
if (!row || row.familyExpiresAt !== null || !timingSafeEqual(supplied, expected))
|
|
221
|
+
throw new WorkspaceDeviceAuthError();
|
|
222
|
+
return this.device(row);
|
|
223
|
+
}
|
|
224
|
+
/** The device behind an access credential, or behind a fixed bearer while those are still accepted. */
|
|
225
|
+
authenticate(token) {
|
|
226
|
+
const now = this.now();
|
|
227
|
+
if (typeof token === 'string' && token.startsWith(ACCESS_PREFIX)) {
|
|
228
|
+
const access = token.length <= 128 ? this.db.prepare('SELECT * FROM access_tokens WHERE hash=? AND expiresAt>?').get(hash(token), now) : undefined;
|
|
229
|
+
const device = access && this.live(String(access.deviceId), now);
|
|
230
|
+
if (!access || !device || device.familyExpiresAt === null)
|
|
231
|
+
throw new WorkspaceDeviceAuthError();
|
|
232
|
+
this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, device.id);
|
|
233
|
+
// Use of this credential shows its refresh credential reached the browser.
|
|
234
|
+
this.db.prepare('UPDATE refresh_tokens SET confirmedAt=? WHERE hash=? AND confirmedAt IS NULL').run(now, String(access.refresh));
|
|
235
|
+
return { ...this.device(device, { lastUsedAt: now }), authorizedUntil: Number(access.expiresAt), rotating: true };
|
|
236
|
+
}
|
|
237
|
+
const device = this.legacy(token, now);
|
|
238
|
+
this.db.prepare('UPDATE devices SET lastUsedAt=? WHERE id=?').run(now, device.id);
|
|
239
|
+
return { ...device, lastUsedAt: now, authorizedUntil: Math.min(device.expiresAt, this.legacySunsetAt), rotating: false };
|
|
240
|
+
}
|
|
241
|
+
/** End the link a refresh credential belongs to, named by its SHA-256: a hash can end a link, never use it. */
|
|
242
|
+
revokeByRefreshHash(refreshHash) {
|
|
243
|
+
if (typeof refreshHash !== 'string' || !/^[a-f0-9]{64}$/.test(refreshHash))
|
|
244
|
+
return undefined;
|
|
245
|
+
const row = this.db.prepare('SELECT deviceId FROM refresh_tokens WHERE hash=?').get(refreshHash);
|
|
246
|
+
if (!row)
|
|
247
|
+
return undefined;
|
|
248
|
+
this.revoke(String(row.deviceId));
|
|
249
|
+
return String(row.deviceId);
|
|
250
|
+
}
|
|
251
|
+
/** Inside a transaction. With cascade, links issued from this one end too. Returns every device ended. */
|
|
252
|
+
revokeRows(id, now, cascade = false) {
|
|
253
|
+
const ended = [], pending = [id];
|
|
254
|
+
while (pending.length) {
|
|
255
|
+
const current = pending.pop();
|
|
256
|
+
if (ended.includes(current))
|
|
257
|
+
continue;
|
|
258
|
+
ended.push(current);
|
|
259
|
+
this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(now, current);
|
|
260
|
+
this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(current);
|
|
261
|
+
this.db.prepare('DELETE FROM access_tokens WHERE deviceId=?').run(current);
|
|
262
|
+
this.db.prepare('DELETE FROM refresh_tokens WHERE deviceId=?').run(current);
|
|
263
|
+
if (cascade)
|
|
264
|
+
for (const child of this.db.prepare('SELECT id FROM devices WHERE issuer=? AND revokedAt IS NULL').all(current))
|
|
265
|
+
pending.push(String(child.id));
|
|
266
|
+
}
|
|
267
|
+
return ended;
|
|
268
|
+
}
|
|
269
|
+
valid(id) { return Boolean(this.live(id, this.now())); }
|
|
85
270
|
list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
|
|
86
|
-
revoke(id) { this.
|
|
271
|
+
revoke(id) { this.transaction(() => this.revokeRows(id, this.now())); }
|
|
87
272
|
/** Retire account links without changing the installation's stable host ID or other data. */
|
|
88
273
|
revokeAll() {
|
|
89
|
-
this.db.exec('
|
|
90
|
-
try {
|
|
91
|
-
this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
|
|
92
|
-
this.db.exec('DELETE FROM enrollments');
|
|
93
|
-
this.db.exec('COMMIT');
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
this.db.exec('ROLLBACK');
|
|
97
|
-
throw error;
|
|
98
|
-
}
|
|
274
|
+
this.transaction(() => { this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now()); this.db.exec('DELETE FROM enrollments; DELETE FROM access_tokens; DELETE FROM refresh_tokens'); });
|
|
99
275
|
}
|
|
100
276
|
close() { this.db.close(); }
|
|
101
277
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.60",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|