@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/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
- await server.onConnect(socket.conn as any, { request: {} } as any);
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() {