@signe/room 1.4.2 → 2.0.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/dist/index.d.ts +258 -22
- package/dist/index.js +1447 -60
- package/dist/index.js.map +1 -1
- package/examples/game/app/client.tsx +2 -2
- package/examples/game/app/components/Admin.tsx +1089 -0
- package/examples/game/app/components/Room.tsx +158 -0
- package/examples/game/party/server.ts +3 -2
- package/examples/game/party/shard.ts +5 -0
- package/examples/game/partykit.json +5 -1
- package/package.json +2 -2
- package/readme.md +226 -2
- package/src/decorators.ts +34 -2
- package/src/index.ts +4 -1
- package/src/interfaces.ts +13 -0
- package/src/jwt.ts +217 -0
- package/src/mock.ts +39 -3
- package/src/server.ts +595 -79
- package/src/shard.ts +244 -0
- package/src/testing.ts +47 -6
- package/src/utils.ts +7 -0
- package/src/world.guard.ts +28 -0
- package/src/world.ts +448 -0
- package/examples/game/app/components/Counter.tsx +0 -82
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Authentication Class for Cloudflare Workers
|
|
3
|
+
* Uses Web Crypto API for JWT signing and verification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface JWTOptions {
|
|
7
|
+
expiresIn?: string | number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface JWTHeader {
|
|
11
|
+
alg: string;
|
|
12
|
+
typ: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface JWTPayload {
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
iat?: number;
|
|
18
|
+
exp?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class JWTAuth {
|
|
22
|
+
private secret: string;
|
|
23
|
+
private encoder: TextEncoder;
|
|
24
|
+
private decoder: TextDecoder;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Constructor for the JWTAuth class
|
|
28
|
+
* @param {string} secret - The secret key used for signing and verifying tokens
|
|
29
|
+
*/
|
|
30
|
+
constructor(secret: string) {
|
|
31
|
+
if (!secret || typeof secret !== 'string') {
|
|
32
|
+
throw new Error('Secret is required and must be a string');
|
|
33
|
+
}
|
|
34
|
+
this.secret = secret;
|
|
35
|
+
this.encoder = new TextEncoder();
|
|
36
|
+
this.decoder = new TextDecoder();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert the secret to a CryptoKey for HMAC operations
|
|
41
|
+
* @returns {Promise<CryptoKey>} - The CryptoKey for HMAC operations
|
|
42
|
+
*/
|
|
43
|
+
private async getSecretKey(): Promise<CryptoKey> {
|
|
44
|
+
const keyData: Uint8Array = this.encoder.encode(this.secret);
|
|
45
|
+
return await crypto.subtle.importKey(
|
|
46
|
+
'raw', // format
|
|
47
|
+
keyData, // key data
|
|
48
|
+
{
|
|
49
|
+
name: 'HMAC',
|
|
50
|
+
hash: { name: 'SHA-256' },
|
|
51
|
+
},
|
|
52
|
+
false, // extractable
|
|
53
|
+
['sign', 'verify'] // key usages
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Base64Url encode a buffer
|
|
59
|
+
* @param {ArrayBuffer} buffer - The buffer to encode
|
|
60
|
+
* @returns {string} - The base64url encoded string
|
|
61
|
+
*/
|
|
62
|
+
private base64UrlEncode(buffer: ArrayBuffer): string {
|
|
63
|
+
const base64: string = btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
|
64
|
+
return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Base64Url decode a string
|
|
69
|
+
* @param {string} base64Url - The base64url encoded string
|
|
70
|
+
* @returns {ArrayBuffer} - The decoded buffer
|
|
71
|
+
*/
|
|
72
|
+
private base64UrlDecode(base64Url: string) {
|
|
73
|
+
const padding: string = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
|
74
|
+
const base64: string = base64Url.replace(/-/g, '+').replace(/_/g, '/') + padding;
|
|
75
|
+
const rawData: string = atob(base64);
|
|
76
|
+
const buffer: Uint8Array = new Uint8Array(rawData.length);
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < rawData.length; i++) {
|
|
79
|
+
buffer[i] = rawData.charCodeAt(i);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return buffer.buffer;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sign a payload and create a JWT token
|
|
87
|
+
* @param {JWTPayload} payload - The payload to include in the token
|
|
88
|
+
* @param {JWTOptions} [options={}] - Options for the token
|
|
89
|
+
* @param {string | number} [options.expiresIn='1h'] - Token expiration time
|
|
90
|
+
* @returns {Promise<string>} - The JWT token
|
|
91
|
+
*/
|
|
92
|
+
public async sign(payload: JWTPayload, options: JWTOptions = {}): Promise<string> {
|
|
93
|
+
if (!payload || typeof payload !== 'object') {
|
|
94
|
+
throw new Error('Payload must be an object');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Set default expiration if not provided
|
|
98
|
+
const expiresIn: string | number = options.expiresIn || '1h';
|
|
99
|
+
|
|
100
|
+
// Calculate expiration time
|
|
101
|
+
let exp: number | undefined;
|
|
102
|
+
if (typeof expiresIn === 'number') {
|
|
103
|
+
exp = Math.floor(Date.now() / 1000) + expiresIn;
|
|
104
|
+
} else if (typeof expiresIn === 'string') {
|
|
105
|
+
const match: RegExpMatchArray | null = expiresIn.match(/^(\d+)([smhd])$/);
|
|
106
|
+
if (match) {
|
|
107
|
+
const value: number = parseInt(match[1]);
|
|
108
|
+
const unit: string = match[2];
|
|
109
|
+
const seconds: number = {
|
|
110
|
+
's': value,
|
|
111
|
+
'm': value * 60,
|
|
112
|
+
'h': value * 60 * 60,
|
|
113
|
+
'd': value * 60 * 60 * 24
|
|
114
|
+
}[unit]!;
|
|
115
|
+
exp = Math.floor(Date.now() / 1000) + seconds;
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error('Invalid expiresIn format. Use a number (seconds) or a string like "1h", "30m", etc.');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Create full payload with claims
|
|
122
|
+
const fullPayload: JWTPayload = {
|
|
123
|
+
...payload,
|
|
124
|
+
iat: Math.floor(Date.now() / 1000),
|
|
125
|
+
exp
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Create header
|
|
129
|
+
const header: JWTHeader = {
|
|
130
|
+
alg: 'HS256',
|
|
131
|
+
typ: 'JWT'
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Encode header and payload
|
|
135
|
+
// @ts-expect-error - TS doesn't have a built-in TextEncoder
|
|
136
|
+
const encodedHeader: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(header)));
|
|
137
|
+
// @ts-expect-error - TS doesn't have a built-in TextEncoder
|
|
138
|
+
const encodedPayload: string = this.base64UrlEncode(this.encoder.encode(JSON.stringify(fullPayload)));
|
|
139
|
+
|
|
140
|
+
// Create signature base
|
|
141
|
+
const signatureBase: string = `${encodedHeader}.${encodedPayload}`;
|
|
142
|
+
|
|
143
|
+
// Get key and sign
|
|
144
|
+
const key: CryptoKey = await this.getSecretKey();
|
|
145
|
+
const signature: ArrayBuffer = await crypto.subtle.sign(
|
|
146
|
+
{ name: 'HMAC' },
|
|
147
|
+
key,
|
|
148
|
+
this.encoder.encode(signatureBase)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Encode signature and create token
|
|
152
|
+
const encodedSignature: string = this.base64UrlEncode(signature);
|
|
153
|
+
return `${signatureBase}.${encodedSignature}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Verify a JWT token and return the decoded payload
|
|
158
|
+
* @param {string} token - The JWT token to verify
|
|
159
|
+
* @returns {Promise<JWTPayload>} - The decoded payload if verification succeeds
|
|
160
|
+
* @throws {Error} - If verification fails
|
|
161
|
+
*/
|
|
162
|
+
public async verify(token: string): Promise<JWTPayload> {
|
|
163
|
+
if (!token || typeof token !== 'string') {
|
|
164
|
+
throw new Error('Token is required and must be a string');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Split token into parts
|
|
168
|
+
const parts: string[] = token.split('.');
|
|
169
|
+
if (parts.length !== 3) {
|
|
170
|
+
throw new Error('Invalid token format');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
174
|
+
|
|
175
|
+
// Decode header and payload
|
|
176
|
+
try {
|
|
177
|
+
// @ts-expect-error - TS doesn't have a built-in TextDecoder
|
|
178
|
+
const header: JWTHeader = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedHeader)));
|
|
179
|
+
// @ts-expect-error - TS doesn't have a built-in TextDecoder
|
|
180
|
+
const payload: JWTPayload = JSON.parse(this.decoder.decode(this.base64UrlDecode(encodedPayload)));
|
|
181
|
+
|
|
182
|
+
// Check algorithm
|
|
183
|
+
if (header.alg !== 'HS256') {
|
|
184
|
+
throw new Error(`Unsupported algorithm: ${header.alg}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check expiration
|
|
188
|
+
const now: number = Math.floor(Date.now() / 1000);
|
|
189
|
+
if (payload.exp && payload.exp < now) {
|
|
190
|
+
throw new Error('Token has expired');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Verify signature
|
|
194
|
+
const key: CryptoKey = await this.getSecretKey();
|
|
195
|
+
const signatureBase: string = `${encodedHeader}.${encodedPayload}`;
|
|
196
|
+
const signature: ArrayBuffer = this.base64UrlDecode(encodedSignature) as ArrayBuffer;
|
|
197
|
+
|
|
198
|
+
const isValid: boolean = await crypto.subtle.verify(
|
|
199
|
+
{ name: 'HMAC' },
|
|
200
|
+
key,
|
|
201
|
+
signature,
|
|
202
|
+
this.encoder.encode(signatureBase)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (!isValid) {
|
|
206
|
+
throw new Error('Invalid signature');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return payload;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof Error) {
|
|
212
|
+
throw new Error(`Token verification failed: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
throw new Error('Token verification failed: Unknown error');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/mock.ts
CHANGED
|
@@ -28,18 +28,54 @@ export class MockPartyClient {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
class MockLobby {
|
|
32
|
+
constructor(public server: Server) {}
|
|
33
|
+
|
|
34
|
+
socket() {
|
|
35
|
+
return new MockPartyClient(this.server)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class MockContext {
|
|
40
|
+
parties: {
|
|
41
|
+
main: Map<string, any>
|
|
42
|
+
} = {
|
|
43
|
+
main: new Map()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
constructor(public room: MockPartyRoom, options: any = {}) {
|
|
47
|
+
const parties = options.parties || {}
|
|
48
|
+
for (let lobbyId in parties) {
|
|
49
|
+
this.parties.main.set(lobbyId, new MockLobby(parties[lobbyId](room)))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
class MockPartyRoom {
|
|
32
56
|
clients: Map<string, MockPartyClient> = new Map();
|
|
33
57
|
storage = new Storage();
|
|
58
|
+
context: MockContext;
|
|
34
59
|
env = {}
|
|
35
60
|
|
|
36
|
-
constructor(public id?: string) {
|
|
61
|
+
constructor(public id?: string, options: any = {}) {
|
|
37
62
|
this.id = id || generateShortUUID()
|
|
63
|
+
this.context = new MockContext(this, {
|
|
64
|
+
parties: options.parties || {}
|
|
65
|
+
})
|
|
66
|
+
this.env = options.env || {}
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
async connection(server: Server) {
|
|
41
70
|
const socket = new MockPartyClient(server);
|
|
42
|
-
|
|
71
|
+
const url = new URL('http://localhost')
|
|
72
|
+
const request = new Request(url.toString(), {
|
|
73
|
+
method: 'GET',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json'
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
await server.onConnect(socket.conn as any, { request } as any);
|
|
43
79
|
this.clients.set(socket.id, socket);
|
|
44
80
|
return socket
|
|
45
81
|
}
|
|
@@ -55,7 +91,7 @@ class MockPartyRoom {
|
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
getConnections() {
|
|
58
|
-
return this.clients;
|
|
94
|
+
return Array.from(this.clients.values()).map((client) => client.conn);
|
|
59
95
|
}
|
|
60
96
|
|
|
61
97
|
clear() {
|