@activeagents/browser-sidecar 0.0.0-stage → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -2
- package/bin/browser-sidecar.mjs +85 -0
- package/lib/config.mjs +191 -0
- package/lib/control-lock.mjs +210 -0
- package/lib/egress-proxy.mjs +127 -0
- package/lib/guard.mjs +86 -0
- package/lib/http-server.mjs +124 -0
- package/lib/input.mjs +137 -0
- package/lib/live-server.mjs +281 -0
- package/lib/live-ticket.mjs +126 -0
- package/lib/mcp-gateway.mjs +339 -0
- package/lib/navigation-guard.mjs +87 -0
- package/lib/network-policy.mjs +264 -0
- package/lib/recorder.mjs +348 -0
- package/lib/screencast.mjs +181 -0
- package/lib/sidecar.mjs +252 -0
- package/lib/storage-state.mjs +76 -0
- package/lib/version.mjs +5 -0
- package/package.json +36 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
4
|
+
|
|
5
|
+
import { sameUser } from './control-lock.mjs';
|
|
6
|
+
import { inputCommand } from './input.mjs';
|
|
7
|
+
|
|
8
|
+
export const LIVE_PATH = '/live';
|
|
9
|
+
|
|
10
|
+
// The close codes a viewer is told why it was disconnected with. The 4xxx
|
|
11
|
+
// codes are this protocol's own.
|
|
12
|
+
export const CLOSE_CODES = Object.freeze({
|
|
13
|
+
stopping: 1001,
|
|
14
|
+
unauthorized: 4401,
|
|
15
|
+
authTimeout: 4408,
|
|
16
|
+
full: 4429,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const AUTH_TIMEOUT_MS = 5000;
|
|
20
|
+
const MAX_MESSAGE_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_CONNECTIONS = 8;
|
|
22
|
+
// A viewer whose socket holds more than this unsent misses frames until it
|
|
23
|
+
// has caught up, and is then sent the latest one.
|
|
24
|
+
const MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const CATCH_UP_MS = 250;
|
|
26
|
+
const TERMINATE_AFTER_MS = 1000;
|
|
27
|
+
const INPUT_TYPES = new Set(['mouse', 'wheel', 'key', 'text']);
|
|
28
|
+
|
|
29
|
+
function parse(data, isBinary) {
|
|
30
|
+
if (isBinary) return null;
|
|
31
|
+
try {
|
|
32
|
+
const message = JSON.parse(data.toString('utf8'));
|
|
33
|
+
return message && typeof message === 'object' && !Array.isArray(message) ? message : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Used to serve the live view: the page on screen as a stream of frames to
|
|
41
|
+
* everyone watching, and the input of the one person who holds control
|
|
42
|
+
* (ControlLock) relayed to it.
|
|
43
|
+
*
|
|
44
|
+
* A connection proves who it is with the ticket in its first message
|
|
45
|
+
* (TicketVerifier), and is closed with CLOSE_CODES.unauthorized, before
|
|
46
|
+
* anything is sent to it, when that message is anything else. Either mode of
|
|
47
|
+
* ticket lets it watch. Each request to take control carries a control
|
|
48
|
+
* ticket of its own, issued to the same user.
|
|
49
|
+
*
|
|
50
|
+
* Viewer to sidecar, as JSON text:
|
|
51
|
+
* { type: "auth", ticket } first, and only first
|
|
52
|
+
* { type: "take_control", ticket } a control ticket issued to the user the connection authenticated as
|
|
53
|
+
* { type: "hand_back" }
|
|
54
|
+
* mouse, wheel, key and text input see input.mjs; dropped unless this connection holds control
|
|
55
|
+
*
|
|
56
|
+
* Sidecar to viewer:
|
|
57
|
+
* { type: "ready", control, agent, page } once the ticket is accepted
|
|
58
|
+
* { type: "frame", data, width, height } a JPEG as base64, and the page's size in CSS pixels
|
|
59
|
+
* { type: "control", held, mine, yours, by, since } who holds control, by name; mine when this
|
|
60
|
+
* connection does, yours when its user does on any
|
|
61
|
+
* { type: "agent", waiting, tool, since } an agent's call is waiting for control to be handed back
|
|
62
|
+
* { type: "page", page: { url, tab, tabs } } the page on screen changed
|
|
63
|
+
* { type: "error", code, message } a request this connection may not make
|
|
64
|
+
*
|
|
65
|
+
* Relayed input is never logged or recorded. Taking and handing back
|
|
66
|
+
* control are, through `onMarker`.
|
|
67
|
+
*/
|
|
68
|
+
export class LiveServer {
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} options
|
|
71
|
+
* @param {{ verify(ticket: unknown): object }} options.verifier a TicketVerifier
|
|
72
|
+
* @param {import('./control-lock.mjs').ControlLock} options.lock
|
|
73
|
+
* @param {import('./screencast.mjs').Screencast} options.screencast
|
|
74
|
+
* @param {(data: object) => void} [options.onMarker] given a takeover's start and end, to record
|
|
75
|
+
* @param {(message: string) => void} [options.log]
|
|
76
|
+
* @param {number} [options.authTimeoutMs] how long a connection may take to send its ticket
|
|
77
|
+
* @param {number} [options.maxConnections]
|
|
78
|
+
*/
|
|
79
|
+
constructor({ verifier, lock, screencast, onMarker = () => {}, log = () => {}, authTimeoutMs = AUTH_TIMEOUT_MS, maxConnections = MAX_CONNECTIONS }) {
|
|
80
|
+
this.verifier = verifier;
|
|
81
|
+
this.lock = lock;
|
|
82
|
+
this.screencast = screencast;
|
|
83
|
+
this.onMarker = onMarker;
|
|
84
|
+
this.log = log;
|
|
85
|
+
this.authTimeoutMs = authTimeoutMs;
|
|
86
|
+
this.maxConnections = maxConnections;
|
|
87
|
+
this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES, perMessageDeflate: false });
|
|
88
|
+
this.connections = new Set();
|
|
89
|
+
this.viewers = new Map();
|
|
90
|
+
this.catchUp = null;
|
|
91
|
+
this.closed = false;
|
|
92
|
+
|
|
93
|
+
lock.on('change', (holder, previous, reason) => this.controlChanged(holder, previous, reason));
|
|
94
|
+
lock.on('agent_waiting', () => this.broadcast({ type: 'agent', ...this.agentState() }));
|
|
95
|
+
lock.on('agent_done', () => this.broadcast({ type: 'agent', ...this.agentState() }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Accepts a WebSocket upgrade that guard.liveRefusal let through.
|
|
100
|
+
*
|
|
101
|
+
* @param {import('node:http').IncomingMessage} request
|
|
102
|
+
* @param {import('node:stream').Duplex} socket
|
|
103
|
+
* @param {Buffer} head
|
|
104
|
+
*/
|
|
105
|
+
handleUpgrade(request, socket, head) {
|
|
106
|
+
if (this.closed) {
|
|
107
|
+
socket.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => this.accept(ws));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
accept(ws) {
|
|
114
|
+
ws.on('error', () => {});
|
|
115
|
+
if (this.connections.size >= this.maxConnections) {
|
|
116
|
+
ws.close(CLOSE_CODES.full, 'Too many viewers');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.connections.add(ws);
|
|
121
|
+
const timer = setTimeout(() => ws.close(CLOSE_CODES.authTimeout, 'No ticket'), this.authTimeoutMs);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
ws.once('message', (data, isBinary) => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
this.authenticate(ws, parse(data, isBinary));
|
|
126
|
+
});
|
|
127
|
+
ws.on('close', () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
this.connections.delete(ws);
|
|
130
|
+
this.leave(ws);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
authenticate(ws, message) {
|
|
135
|
+
const result = message?.type === 'auth' ? this.verifier.verify(message.ticket) : { refusal: 'no ticket first' };
|
|
136
|
+
if (result.refusal) {
|
|
137
|
+
this.log(`live view: refused a viewer (${result.refusal})`);
|
|
138
|
+
ws.close(CLOSE_CODES.unauthorized, result.refusal);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { sub, name } = result.claims;
|
|
143
|
+
const viewer = { id: randomUUID(), ws, user: { id: sub, name }, behind: false };
|
|
144
|
+
this.viewers.set(ws, viewer);
|
|
145
|
+
ws.on('message', (data, isBinary) => this.receive(viewer, parse(data, isBinary)));
|
|
146
|
+
|
|
147
|
+
this.send(viewer, { type: 'ready', control: this.controlState(viewer), agent: this.agentState(), page: this.screencast.pageInfo });
|
|
148
|
+
if (this.screencast.lastFrame) this.sendFrame(viewer, JSON.stringify(frameMessage(this.screencast.lastFrame)));
|
|
149
|
+
void this.screencast.start();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
receive(viewer, message) {
|
|
153
|
+
if (!message) return;
|
|
154
|
+
|
|
155
|
+
if (message.type === 'take_control') this.takeControl(viewer, message.ticket);
|
|
156
|
+
else if (message.type === 'hand_back') this.lock.release(viewer.id);
|
|
157
|
+
else if (INPUT_TYPES.has(message.type)) this.relay(viewer, message);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A ticket per request, rather than one per connection, means a person
|
|
161
|
+
// whose permission is withdrawn cannot take control again on a connection
|
|
162
|
+
// they opened before.
|
|
163
|
+
takeControl(viewer, ticket) {
|
|
164
|
+
const { claims } = this.verifier.verify(ticket);
|
|
165
|
+
if (claims?.mode !== 'control' || claims.sub !== viewer.user.id) {
|
|
166
|
+
this.send(viewer, { type: 'error', code: 'view_only', message: 'Taking over this browser needs a control ticket issued to you' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { taken, holder } = this.lock.acquire(viewer.id, viewer.user);
|
|
171
|
+
if (!taken) this.send(viewer, { type: 'error', code: 'held', message: `${holder?.user.name ?? 'Someone else'} is driving the browser` });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
relay(viewer, message) {
|
|
175
|
+
if (this.lock.holder?.key !== viewer.id) return;
|
|
176
|
+
|
|
177
|
+
const command = inputCommand(message, this.screencast.metadata);
|
|
178
|
+
if (!command) return;
|
|
179
|
+
this.screencast.dispatch(...command).catch(() => this.log(`live view: ${command[0]} was not delivered`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
leave(ws) {
|
|
183
|
+
const viewer = this.viewers.get(ws);
|
|
184
|
+
if (!viewer) return;
|
|
185
|
+
|
|
186
|
+
this.viewers.delete(ws);
|
|
187
|
+
this.lock.disconnected(viewer.id);
|
|
188
|
+
if (this.viewers.size === 0) void this.screencast.stop();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
controlState(viewer) {
|
|
192
|
+
const holder = this.lock.holder;
|
|
193
|
+
if (!holder) return { held: false, mine: false, yours: false, by: null, since: null };
|
|
194
|
+
|
|
195
|
+
const mine = holder.key === viewer.id;
|
|
196
|
+
return { held: true, mine, yours: mine || sameUser(holder.user, viewer.user), by: holder.user.name, since: holder.since };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
agentState() {
|
|
200
|
+
const waiting = this.lock.agentWaiting;
|
|
201
|
+
return waiting ? { waiting: true, tool: waiting.tool, since: waiting.since } : { waiting: false, tool: null, since: null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Control moving between connections of one user is no takeover of its own.
|
|
205
|
+
controlChanged(holder, previous, reason) {
|
|
206
|
+
for (const viewer of this.viewers.values()) this.send(viewer, { type: 'control', ...this.controlState(viewer) });
|
|
207
|
+
|
|
208
|
+
if (holder && !previous) this.onMarker({ label: 'takeover_started', source: 'human', user: holder.user });
|
|
209
|
+
else if (!holder && previous) this.onMarker({ label: 'takeover_ended', source: 'human', user: previous.user, reason });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Sends a frame the screencast produced to everyone watching.
|
|
214
|
+
*
|
|
215
|
+
* @param {{ data: string, metadata: object }} frame
|
|
216
|
+
*/
|
|
217
|
+
frame(frame) {
|
|
218
|
+
const text = JSON.stringify(frameMessage(frame));
|
|
219
|
+
for (const viewer of this.viewers.values()) this.sendFrame(viewer, text);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Tells everyone watching which page is on screen.
|
|
223
|
+
pageChanged() {
|
|
224
|
+
this.broadcast({ type: 'page', page: this.screencast.pageInfo });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
sendFrame(viewer, text) {
|
|
228
|
+
if (viewer.ws.readyState !== WebSocket.OPEN) return;
|
|
229
|
+
if (viewer.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
|
|
230
|
+
viewer.behind = true;
|
|
231
|
+
this.scheduleCatchUp();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
viewer.behind = false;
|
|
235
|
+
viewer.ws.send(text);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
scheduleCatchUp() {
|
|
239
|
+
if (this.catchUp) return;
|
|
240
|
+
|
|
241
|
+
this.catchUp = setInterval(() => {
|
|
242
|
+
const behind = [...this.viewers.values()].filter((viewer) => viewer.behind);
|
|
243
|
+
if (behind.length === 0 || !this.screencast.lastFrame) {
|
|
244
|
+
clearInterval(this.catchUp);
|
|
245
|
+
this.catchUp = null;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const text = JSON.stringify(frameMessage(this.screencast.lastFrame));
|
|
249
|
+
for (const viewer of behind) this.sendFrame(viewer, text);
|
|
250
|
+
}, CATCH_UP_MS);
|
|
251
|
+
this.catchUp.unref?.();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
send(viewer, message) {
|
|
255
|
+
if (viewer.ws.readyState === WebSocket.OPEN) viewer.ws.send(JSON.stringify(message));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
broadcast(message) {
|
|
259
|
+
for (const viewer of this.viewers.values()) this.send(viewer, message);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Ends every connection and releases control, refusing the agent calls
|
|
263
|
+
// still waiting for it: the browser is stopping.
|
|
264
|
+
close() {
|
|
265
|
+
if (this.closed) return;
|
|
266
|
+
this.closed = true;
|
|
267
|
+
clearInterval(this.catchUp);
|
|
268
|
+
this.catchUp = null;
|
|
269
|
+
|
|
270
|
+
this.lock.close();
|
|
271
|
+
for (const ws of this.connections) {
|
|
272
|
+
ws.close(CLOSE_CODES.stopping, 'The browser stopped');
|
|
273
|
+
setTimeout(() => ws.terminate(), TERMINATE_AFTER_MS).unref?.();
|
|
274
|
+
}
|
|
275
|
+
this.wss.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function frameMessage({ data, metadata }) {
|
|
280
|
+
return { type: 'frame', data, width: metadata.deviceWidth, height: metadata.deviceHeight };
|
|
281
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// The dashboard derives the same key from the same token, so a ticket made
|
|
4
|
+
// for one browser start is useless against any other.
|
|
5
|
+
export const TICKET_CONTEXT = 'activeagents/browser-live-ticket/v1';
|
|
6
|
+
export const MODES = ['view', 'control'];
|
|
7
|
+
// The longest a ticket may live, whatever it claims, and how far ahead of
|
|
8
|
+
// this machine's clock the dashboard's may be.
|
|
9
|
+
export const MAX_TICKET_SECONDS = 60;
|
|
10
|
+
const CLOCK_SKEW_SECONDS = 5;
|
|
11
|
+
const MAX_TICKET_CHARS = 2048;
|
|
12
|
+
const MAX_NAME_CHARS = 80;
|
|
13
|
+
const TICKET = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
14
|
+
const TICKET_ID = /^[A-Za-z0-9_-]{16,64}$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the key tickets are signed with: an HMAC-SHA256 of TICKET_CONTEXT
|
|
18
|
+
* under the browser's bearer token.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} token
|
|
21
|
+
* @returns {Buffer}
|
|
22
|
+
*/
|
|
23
|
+
export function ticketKey(token) {
|
|
24
|
+
return createHmac('sha256', token).update(TICKET_CONTEXT).digest();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sign(key, payload) {
|
|
28
|
+
return createHmac('sha256', key).update(payload).digest();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns a ticket for `claims`, signed with `key`, in the form the
|
|
33
|
+
* dashboard issues: base64url(JSON claims), a dot, base64url(HMAC-SHA256 of
|
|
34
|
+
* that first part). Used by tests; the dashboard issues the real ones.
|
|
35
|
+
*
|
|
36
|
+
* @param {Buffer} key see ticketKey
|
|
37
|
+
* @param {object} claims
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function signTicket(key, claims) {
|
|
41
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
42
|
+
return `${payload}.${sign(key, payload).toString('base64url')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nullableString(value, limit) {
|
|
46
|
+
if (value === null || value === undefined) return null;
|
|
47
|
+
return typeof value === 'string' ? value.slice(0, limit) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Used to check the tickets a viewer presents. A ticket is accepted once:
|
|
52
|
+
* signed with this browser's key, naming this sandbox session and a mode,
|
|
53
|
+
* issued for at most MAX_TICKET_SECONDS, unexpired, and not seen before.
|
|
54
|
+
*
|
|
55
|
+
* Claims:
|
|
56
|
+
* v 1
|
|
57
|
+
* sid the sandbox session
|
|
58
|
+
* sub who it was issued to, or null when the dashboard has no signed-in user
|
|
59
|
+
* name their name to show other viewers, or null
|
|
60
|
+
* mode "view" (watch) or "control" (take over)
|
|
61
|
+
* iat when it was issued, in epoch seconds
|
|
62
|
+
* exp when it expires, in epoch seconds
|
|
63
|
+
* jti its unique id
|
|
64
|
+
*/
|
|
65
|
+
export class TicketVerifier {
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} options
|
|
68
|
+
* @param {Buffer} options.key see ticketKey
|
|
69
|
+
* @param {string} options.sessionId the sandbox session this browser belongs to
|
|
70
|
+
* @param {() => number} [options.now] epoch milliseconds
|
|
71
|
+
*/
|
|
72
|
+
constructor({ key, sessionId, now = Date.now }) {
|
|
73
|
+
this.key = key;
|
|
74
|
+
this.sessionId = sessionId;
|
|
75
|
+
this.now = now;
|
|
76
|
+
this.used = new Map();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks `ticket` and, when it is valid, marks it used.
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} ticket
|
|
83
|
+
* @returns {{ claims: { sub: string|null, name: string|null, mode: string, exp: number } } | { refusal: string }}
|
|
84
|
+
*/
|
|
85
|
+
verify(ticket) {
|
|
86
|
+
if (typeof ticket !== 'string' || ticket.length > MAX_TICKET_CHARS || !TICKET.test(ticket)) return { refusal: 'malformed' };
|
|
87
|
+
|
|
88
|
+
const [payload, signature] = ticket.split('.');
|
|
89
|
+
const given = Buffer.from(signature, 'base64url');
|
|
90
|
+
const expected = sign(this.key, payload);
|
|
91
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { refusal: 'bad signature' };
|
|
92
|
+
|
|
93
|
+
let claims;
|
|
94
|
+
try {
|
|
95
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
96
|
+
} catch {
|
|
97
|
+
return { refusal: 'malformed' };
|
|
98
|
+
}
|
|
99
|
+
if (!claims || typeof claims !== 'object' || claims.v !== 1) return { refusal: 'malformed' };
|
|
100
|
+
if (claims.sid !== this.sessionId) return { refusal: 'another sandbox' };
|
|
101
|
+
if (!MODES.includes(claims.mode)) return { refusal: 'unknown mode' };
|
|
102
|
+
|
|
103
|
+
const { iat, exp, jti } = claims;
|
|
104
|
+
const now = this.now() / 1000;
|
|
105
|
+
if (!Number.isInteger(iat) || !Number.isInteger(exp) || exp <= iat || exp - iat > MAX_TICKET_SECONDS) return { refusal: 'bad lifetime' };
|
|
106
|
+
if (iat > now + CLOCK_SKEW_SECONDS) return { refusal: 'issued in the future' };
|
|
107
|
+
if (exp <= now) return { refusal: 'expired' };
|
|
108
|
+
if (typeof jti !== 'string' || !TICKET_ID.test(jti)) return { refusal: 'malformed' };
|
|
109
|
+
|
|
110
|
+
const sub = nullableString(claims.sub, 256);
|
|
111
|
+
const name = nullableString(claims.name, MAX_NAME_CHARS);
|
|
112
|
+
if (sub === undefined || name === undefined) return { refusal: 'malformed' };
|
|
113
|
+
|
|
114
|
+
this.forgetExpired(now);
|
|
115
|
+
if (this.used.has(jti)) return { refusal: 'already used' };
|
|
116
|
+
this.used.set(jti, exp);
|
|
117
|
+
|
|
118
|
+
return { claims: { sub, name, mode: claims.mode, exp } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
forgetExpired(now) {
|
|
122
|
+
for (const [jti, exp] of this.used) {
|
|
123
|
+
if (exp <= now) this.used.delete(jti);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|