Skip to content

Commit ee252fb

Browse files
authored
feat: add onTokenSync hook for auth token re-sync (#1001)
* init token sync * refactor: use existing auth message type for token sync * refactor(provider): sentToken onOpen and improve testing * docs(onTokenSync): init onTokenSync in docs * refactor: close WSconn if onTokenSync throws an err * refactor: close connection not the ws
1 parent 2c249da commit ee252fb

12 files changed

Lines changed: 543 additions & 19 deletions

File tree

docs/server/hooks.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ By way of illustration, if a user isn’t allowed to connect: Just throw an erro
3434
| `onConnect` | When a connection is established | [Read more](/server/hooks#on-connect) |
3535
| `connected` | After a connection has been establied | [Read more](/server/hooks#connected) |
3636
| `onAuthenticate` | When authentication is required | [Read more](/server/hooks#on-authenticate) |
37+
| `onTokenSync` | When token synchronization occurs | [Read more](/server/hooks#on-token-sync) |
3738
| `onAwarenessUpdate` | When awareness changed | [Read more](/server/hooks#on-awareness-update) |
3839
| `onLoadDocument` | During the creation of a new document | [Read more](/server/hooks#on-load-document) |
3940
| `afterLoadDocument` | After a document is created | [Read more](/server/hooks#after-load-document) |
@@ -213,6 +214,73 @@ const server = new Server({
213214
server.listen();
214215
```
215216

217+
### onTokenSync
218+
219+
The `onTokenSync` hook is called when the server receives a token synchronization request from a connected provider. This enables the server to validate user tokens during active sessions without requiring a full reconnection.
220+
221+
**Hook payload**
222+
223+
The `data` passed to the `onTokenSync` hook has the following attributes:
224+
225+
```js
226+
const data = {
227+
context: any,
228+
document: Doc,
229+
documentName: string,
230+
instance: Hocuspocus,
231+
requestHeaders: IncomingHttpHeaders,
232+
requestParameters: URLSearchParams,
233+
socketId: string,
234+
token: string,
235+
connectionConfig: {
236+
readOnly: boolean,
237+
},
238+
connection: Connection,
239+
};
240+
```
241+
242+
**Example**
243+
244+
```js
245+
import { Server } from "@hocuspocus/server";
246+
247+
const server = new Server({
248+
async onTokenSync({ token, context, connection }) {
249+
// Validate the current token
250+
const isValid = await validateToken(token);
251+
252+
if (!isValid) {
253+
throw new Error("Token has expired or is invalid");
254+
}
255+
256+
// Update permissions if changed
257+
const permissions = await getUserPermissions(context.userId);
258+
if (permissions.readOnly !== connection.readOnly) {
259+
connection.readOnly = permissions.readOnly;
260+
}
261+
262+
return { lastTokenSync: new Date() };
263+
},
264+
});
265+
266+
server.listen();
267+
```
268+
269+
**Provider Usage**
270+
271+
```js
272+
// Provider sends token to server
273+
provider.sendToken();
274+
```
275+
276+
**Server Usage**
277+
278+
```js
279+
// Server requests token from provider
280+
const connection = document.connections.values().next().value?.connection;
281+
connection.requestToken();
282+
```
283+
216284
### onAwarenessUpdate
217285
218286
The `onAwarenessUpdate` hooks are called when awareness changed ([Provider Awareness API](/provider/events)).

packages/common/src/auth.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as encoding from "lib0/encoding";
22
import * as decoding from "lib0/decoding";
33

4-
enum AuthMessageType {
4+
export enum AuthMessageType {
55
Token = 0,
66
PermissionDenied = 1,
77
Authenticated = 2,
@@ -31,12 +31,23 @@ export const writeAuthenticated = (
3131
encoding.writeVarString(encoder, scope);
3232
};
3333

34+
export const writeTokenSyncRequest = (
35+
encoder: encoding.Encoder,
36+
) => {
37+
encoding.writeVarUint(encoder, AuthMessageType.Token);
38+
};
39+
3440
export const readAuthMessage = (
3541
decoder: decoding.Decoder,
42+
sendToken: () => void,
3643
permissionDeniedHandler: (reason: string) => void,
3744
authenticatedHandler: (scope: string) => void,
3845
) => {
3946
switch (decoding.readVarUint(decoder)) {
47+
case AuthMessageType.Token: {
48+
sendToken();
49+
break;
50+
}
4051
case AuthMessageType.PermissionDenied: {
4152
permissionDeniedHandler(decoding.readVarString(decoder));
4253
break;

packages/provider/src/HocuspocusProvider.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,21 @@ export class HocuspocusProvider extends EventEmitter {
304304
});
305305
}
306306

307+
async sendToken() {
308+
let token: string | null;
309+
try {
310+
token = await this.getToken();
311+
} catch (error) {
312+
this.permissionDeniedHandler(`Failed to get token during sendToken(): ${error}`);
313+
return;
314+
}
315+
316+
this.send(AuthenticationMessage, {
317+
token: token ?? "",
318+
documentName: this.configuration.name,
319+
});
320+
}
321+
307322
documentUpdateHandler(update: Uint8Array, origin: any) {
308323
if (origin === this) {
309324
return;
@@ -374,20 +389,7 @@ export class HocuspocusProvider extends EventEmitter {
374389
this.isAuthenticated = false;
375390

376391
this.emit("open", { event });
377-
378-
let token: string | null;
379-
try {
380-
token = await this.getToken();
381-
} catch (error) {
382-
this.permissionDeniedHandler(`Failed to get token: ${error}`);
383-
return;
384-
}
385-
386-
this.send(AuthenticationMessage, {
387-
token: token ?? "",
388-
documentName: this.configuration.name,
389-
});
390-
392+
await this.sendToken();
391393
this.startSync();
392394
}
393395

packages/provider/src/MessageReceiver.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export class MessageReceiver {
117117

118118
readAuthMessage(
119119
message.decoder,
120+
provider.sendToken.bind(provider),
120121
provider.permissionDeniedHandler.bind(provider),
121122
provider.authenticatedHandler.bind(provider),
122123
);

packages/server/src/ClientConnection.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,26 @@ export class ClientConnection {
249249
this.documentConnectionsEstablished.delete(documentName);
250250
});
251251

252+
connection.onTokenSyncCallback(async (payload) => {
253+
try {
254+
return await this.hooks("onTokenSync", {
255+
...hookPayload,
256+
...payload,
257+
connection,
258+
documentName,
259+
}, (contextAdditions: any) => {
260+
hookPayload.context = {
261+
...hookPayload.context,
262+
...contextAdditions,
263+
};
264+
});
265+
} catch (err: any) {
266+
console.error(err);
267+
const error = { ...Unauthorized, ...err };
268+
connection.close({ code: error.code, reason: error.reason });
269+
}
270+
});
271+
252272
this.documentConnections[documentName] = connection;
253273

254274
// If the WebSocket has already disconnected (wow, that was fast) – then

packages/server/src/Connection.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type Document from "./Document.ts";
99
import { IncomingMessage } from "./IncomingMessage.ts";
1010
import { MessageReceiver } from "./MessageReceiver.ts";
1111
import { OutgoingMessage } from "./OutgoingMessage.ts";
12-
import type { beforeSyncPayload, onStatelessPayload } from "./types.ts";
12+
import type { beforeSyncPayload, onTokenSyncPayload, onStatelessPayload } from "./types.ts";
1313

1414
export class Connection {
1515
webSocket: WebSocket;
@@ -29,6 +29,7 @@ export class Connection {
2929
payload: Pick<beforeSyncPayload, "type" | "payload">,
3030
) => Promise.resolve(),
3131
statelessCallback: (payload: onStatelessPayload) => Promise.resolve(),
32+
onTokenSyncCallback: (payload: Partial<onTokenSyncPayload>) => Promise.resolve(),
3233
};
3334

3435
socketId: string;
@@ -106,6 +107,17 @@ export class Connection {
106107
return this;
107108
}
108109

110+
/**
111+
* Set a callback that will be triggered when on token sync message is received
112+
*/
113+
onTokenSyncCallback(
114+
callback: (payload: onTokenSyncPayload) => Promise<void>,
115+
): Connection {
116+
this.callbacks.onTokenSyncCallback = callback;
117+
118+
return this;
119+
}
120+
109121
/**
110122
* Send the given message
111123
*/
@@ -138,6 +150,15 @@ export class Connection {
138150
this.send(message.toUint8Array());
139151
}
140152

153+
/**
154+
* Request current token from the client
155+
*/
156+
public requestToken(): void {
157+
const message = new OutgoingMessage(this.document.name).writeTokenSyncRequest();
158+
159+
this.send(message.toUint8Array());
160+
}
161+
141162
/**
142163
* Graceful wrapper around the WebSocket close method.
143164
*/

packages/server/src/Hocuspocus.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export class Hocuspocus {
106106
onConnect: this.configuration.onConnect,
107107
connected: this.configuration.connected,
108108
onAuthenticate: this.configuration.onAuthenticate,
109+
onTokenSync: this.configuration.onTokenSync,
109110
onLoadDocument: this.configuration.onLoadDocument,
110111
afterLoadDocument: this.configuration.afterLoadDocument,
111112
beforeHandleMessage: this.configuration.beforeHandleMessage,

packages/server/src/MessageReceiver.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type Document from "./Document.ts";
1515
import type { IncomingMessage } from "./IncomingMessage.ts";
1616
import { OutgoingMessage } from "./OutgoingMessage.ts";
1717
import { MessageType } from "./types.ts";
18+
import { AuthMessageType } from "@hocuspocus/common";
1819

1920
export class MessageReceiver {
2021
message: IncomingMessage;
@@ -103,11 +104,19 @@ export class MessageReceiver {
103104
break;
104105
}
105106

106-
case MessageType.Auth:
107+
case MessageType.Auth: {
108+
const authType = message.readVarUint();
109+
if (authType === AuthMessageType.Token) {
110+
connection?.callbacks.onTokenSyncCallback({
111+
token: message.readVarString(),
112+
});
113+
break;
114+
}
107115
console.error(
108116
"Received an authentication message on a connection that is already fully authenticated. Probably your provider has been destroyed + recreated really fast.",
109117
);
110118
break;
119+
}
111120

112121
default:
113122
console.error(

packages/server/src/OutgoingMessage.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Awareness } from "y-protocols/awareness";
1010
import { encodeAwarenessUpdate } from "y-protocols/awareness";
1111
import { writeSyncStep1, writeUpdate } from "y-protocols/sync";
1212

13-
import { writeAuthenticated, writePermissionDenied } from "@hocuspocus/common";
13+
import { writeAuthenticated, writePermissionDenied, writeTokenSyncRequest } from "@hocuspocus/common";
1414
import type Document from "./Document.ts";
1515
import { MessageType } from "./types.ts";
1616

@@ -70,6 +70,16 @@ export class OutgoingMessage {
7070
return this;
7171
}
7272

73+
writeTokenSyncRequest(): OutgoingMessage {
74+
this.type = MessageType.Auth;
75+
this.category = "TokenSync";
76+
77+
writeVarUint(this.encoder, MessageType.Auth);
78+
writeTokenSyncRequest(this.encoder);
79+
80+
return this;
81+
}
82+
7383
writeAuthenticated(readonly: boolean): OutgoingMessage {
7484
this.type = MessageType.Auth;
7585
this.category = "Authenticated";

packages/server/src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface Extension {
4242
onConnect?(data: onConnectPayload): Promise<any>;
4343
connected?(data: connectedPayload): Promise<any>;
4444
onAuthenticate?(data: onAuthenticatePayload): Promise<any>;
45+
onTokenSync?(data: onTokenSyncPayload): Promise<any>;
4546
onCreateDocument?(data: onCreateDocumentPayload): Promise<any>;
4647
onLoadDocument?(data: onLoadDocumentPayload): Promise<any>;
4748
afterLoadDocument?(data: afterLoadDocumentPayload): Promise<any>;
@@ -69,6 +70,7 @@ export type HookName =
6970
| "onConnect"
7071
| "connected"
7172
| "onAuthenticate"
73+
| "onTokenSync"
7274
| "onCreateDocument"
7375
| "onLoadDocument"
7476
| "afterLoadDocument"
@@ -93,6 +95,7 @@ export type HookPayloadByName = {
9395
onConnect: onConnectPayload;
9496
connected: connectedPayload;
9597
onAuthenticate: onAuthenticatePayload;
98+
onTokenSync: onTokenSyncPayload;
9699
onCreateDocument: onCreateDocumentPayload;
97100
onLoadDocument: onLoadDocumentPayload;
98101
afterLoadDocument: afterLoadDocumentPayload;
@@ -174,6 +177,19 @@ export interface onAuthenticatePayload {
174177
connectionConfig: ConnectionConfiguration;
175178
}
176179

180+
export interface onTokenSyncPayload {
181+
context: any;
182+
document: Document;
183+
documentName: string;
184+
instance: Hocuspocus;
185+
requestHeaders: IncomingHttpHeaders;
186+
requestParameters: URLSearchParams;
187+
socketId: string;
188+
token: string;
189+
connectionConfig: ConnectionConfiguration;
190+
connection: Connection;
191+
}
192+
177193
export interface onCreateDocumentPayload {
178194
context: any;
179195
documentName: string;

0 commit comments

Comments
 (0)