commit 0a763d2ec535b1d1b3bbc582e5cb9e82327a8709 Author: nvms Date: Tue Aug 27 18:16:34 2024 -0400 relocate these diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b559c91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +^.env$ + +packages/smol +docs +examples diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..73849d9 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,23 @@ +{ + "printWidth": 80, + "overrides": [ + { + "files": "packages/otp/src/index.test.ts", + "options": { + "printWidth": 250 + } + }, + { + "files": "packages/otp/src/index.ts", + "options": { + "printWidth": 250 + } + }, + { + "files": "packages/match/src/index.ts", + "options": { + "printWidth": 250 + } + } + ] +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..cd4f67f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,73 @@ +Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright 2024 Jonathan Pyers + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1aef7e7 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# prsm + +The **prsm** package namespace contains a collection of packages that have been curated over the years by [nvms](https://github.com/nvms). + +* @prsm/arc [![NPM version](https://img.shields.io/npm/v/@prsm/arc?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/arc) +* @prsm/duplex [![NPM version](https://img.shields.io/npm/v/@prsm/duplex?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/duplex) +* @prsm/express-keepalive-ws [![NPM version](https://img.shields.io/npm/v/@prsm/express-keepalive-ws?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/express-keepalive-ws) +* @prsm/express-session-auth [![NPM version](https://img.shields.io/npm/v/@prsm/express-session-auth?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/express-session-auth) +* @prsm/hash [![NPM version](https://img.shields.io/npm/v/@prsm/hash?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/hash) +* @prsm/ids [![NPM version](https://img.shields.io/npm/v/@prsm/ids?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/ids) +* @prsm/jwt [![NPM version](https://img.shields.io/npm/v/@prsm/jwt?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/jwt) +* @prsm/keepalive-ws [![NPM version](https://img.shields.io/npm/v/@prsm/keepalive-ws?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/keepalive-ws) +* @prsm/ms [![NPM version](https://img.shields.io/npm/v/@prsm/ms?color=0B3437&label=)](https://www.npmjs.com/package/@prsm/ms) diff --git a/packages/arc b/packages/arc new file mode 160000 index 0000000..595e0b4 --- /dev/null +++ b/packages/arc @@ -0,0 +1 @@ +Subproject commit 595e0b41961a6504b957173fa6470e4e21d16296 diff --git a/packages/duplex/.npmignore b/packages/duplex/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/duplex/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/duplex/README.md b/packages/duplex/README.md new file mode 100644 index 0000000..a31e68c --- /dev/null +++ b/packages/duplex/README.md @@ -0,0 +1,82 @@ +# duplex + +[![NPM version](https://img.shields.io/npm/v/@prsm/duplex?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/duplex) + +An optionally-secure, full-duplex TCP command server and client on top of `node:tls` and `node:net`. + +## Server + +```typescript +import { CommandServer } from "@prsm/duplex"; + +// An insecure CommandServer (`Server` from `node:net`) +const server = new CommandServer({ + host: "localhost", + port: 3351, + secure: false, +}); + +// A secure CommandServer (`Server` from `node:tls`) +// https://nodejs.org/api/tls.html#new-tlstlssocketsocket-options +const server = new CommandServer({ + host: "localhost", + port: 3351, + secure: true, + key: fs.readFileSync("certs/server/server.key"), + cert: fs.readFileSync("certs/server/server.crt"), + ca: fs.readFileSync("certs/server/ca.crt"), + requestCert: true, +}); + +// ------------------- +// Defining a command handler +server.command(0, async (payload: any, connection: Connection) => { + return { ok: "OK" }; +}); +``` + +## Client + +```typescript +import { CommandClient } from "@prsm/duplex"; + +// An insecure client (`Socket` from `node:net`) +const client = new CommandClient({ + host: "localhost", + port: 3351, + secure: false, +}); + +// A secure client (`TLSSocket` from `node:tls`) +const client = new CommandClient({ + host: "localhost", + port: 3351, + secure: true, + key: fs.readFileSync("certs/client/client.key"), + cert: fs.readFileSync("certs/client/client.crt"), + ca: fs.readFileSync("certs/ca/ca.crt"), +}); + +// ------------------- +// Awaiting the response +try { + const response = await client.command(0, { some: "payload" }, 1000); + // command^ ^payload ^expiration + // response: { ok: "OK" }; +} catch (error) { + console.error(error); +} + +// ...or receiving the response in a callback +const callback = (response: any, error: CodeError) => { + if (error) { + console.error(error.code); + return; + } + + // response is { ok: "OK" } +}; + +// Sending a command to the server +client.command(0, { some: "payload" }, 1000, callback); +``` diff --git a/packages/duplex/bump.config.ts b/packages/duplex/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/duplex/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/duplex/bun.lockb b/packages/duplex/bun.lockb new file mode 100755 index 0000000..e080ae2 Binary files /dev/null and b/packages/duplex/bun.lockb differ diff --git a/packages/duplex/package.json b/packages/duplex/package.json new file mode 100644 index 0000000..755fc52 --- /dev/null +++ b/packages/duplex/package.json @@ -0,0 +1,27 @@ +{ + "name": "@prsm/duplex", + "version": "1.1.12", + "author": "nvms", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "description": "", + "keywords": [], + "license": "Apache-2.0", + "scripts": { + "build": "tsup", + "release": "bumpp package.json && npm publish --access public" + }, + "type": "module", + "devDependencies": { + "@types/node": "^22.4.1", + "bumpp": "^9.5.1", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + } +} diff --git a/packages/duplex/src/client/commandclient.ts b/packages/duplex/src/client/commandclient.ts new file mode 100644 index 0000000..be385de --- /dev/null +++ b/packages/duplex/src/client/commandclient.ts @@ -0,0 +1,230 @@ +import { EventEmitter } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import { CodeError } from "../common/codeerror"; +import { Command } from "../common/command"; +import { Connection } from "../common/connection"; +import { ErrorSerializer } from "../common/errorserializer"; +import { Status } from "../common/status"; +import { IdManager } from "../server/ids"; +import { Queue } from "./queue"; + +export type TokenClientOptions = tls.ConnectionOptions & net.NetConnectOpts & { + secure: boolean; +}; + +class TokenClient extends EventEmitter { + public options: TokenClientOptions; + private socket: tls.TLSSocket | net.Socket; + private connection: Connection | null = null; + private hadError: boolean; + status: Status; + + constructor(options: TokenClientOptions) { + super(); + this.options = options; + this.connect(); + } + + connect(callback?: () => void) { + if (this.status >= Status.CLOSED) { + return false; + } + + this.hadError = false; + this.status = Status.CONNECTING; + + if (this.options.secure) { + this.socket = tls.connect(this.options, callback); + } else { + this.socket = net.connect(this.options, callback); + } + + this.connection = null; + this.applyListeners(); + + return true; + } + + close(callback?: () => void) { + if (this.status <= Status.CLOSED) return false; + + this.status = Status.CLOSED; + this.socket.end(() => { + this.connection = null; + if (callback) callback(); + }); + + return true; + } + + send(buffer: Buffer) { + if (this.connection) { + return this.connection.send(buffer); + } + + return false; + } + + private applyListeners() { + this.socket.on("error", (error) => { + this.hadError = true; + this.emit("error", error); + }); + + this.socket.on("close", () => { + this.status = Status.OFFLINE; + this.emit("close", this.hadError); + }); + + this.socket.on("secureConnect", () => { + this.updateConnection(); + this.status = Status.ONLINE; + this.emit("connect"); + }); + + this.socket.on("connect", () => { + this.updateConnection(); + this.status = Status.ONLINE; + this.emit("connect"); + }); + } + + private updateConnection() { + const connection = new Connection(this.socket); + + connection.on("token", (token) => { + this.emit("token", token, connection); + }); + + connection.on("remoteClose", () => { + this.emit("remoteClose", connection); + }); + + this.connection = connection; + } +} + +class QueueClient extends TokenClient { + private queue = new Queue(); + + constructor(options: TokenClientOptions) { + super(options); + this.applyEvents(); + } + + sendBuffer(buffer: Buffer, expiresIn: number) { + const success = this.send(buffer); + + if (!success) { + this.queue.add(buffer, expiresIn); + } + } + + private applyEvents() { + this.on("connect", () => { + while (!this.queue.isEmpty) { + const item = this.queue.pop(); + this.sendBuffer(item.value, item.expiresIn); + } + }); + } + + close() { + return super.close(); + } +} + +export class CommandClient extends QueueClient { + private ids = new IdManager(0xFFFF); + private callbacks: { + [id: number]: (error: Error | null, result?: any) => void + } = {}; + + constructor(options: TokenClientOptions) { + super(options); + this.init(); + } + + private init() { + this.on("token", (buffer: Buffer) => { + try { + const data = Command.parse(buffer); + + if (this.callbacks[data.id]) { + if (data.command === 255) { + const error = ErrorSerializer.deserialize(data.payload); + this.callbacks[data.id](error, undefined); + } else { + this.callbacks[data.id](null, data.payload); + } + } + } catch (error) { + this.emit("error", error); + } + }); + } + + async command(command: number, payload: any, expiresIn: number = 30_000, callback: (result: any, error: CodeError | Error | null) => void | undefined = undefined) { + if (command === 255) { + throw new CodeError("Command 255 is reserved.", "ERESERVED", "CommandError"); + } + + const id = this.ids.reserve(); + const buffer = Command.toBuffer({ id, command, payload }) + + this.sendBuffer(buffer, expiresIn); + + // No 0, null or Infinity. + // Fallback to a reasonable default. + if (expiresIn === 0 || expiresIn === null || expiresIn === Infinity) { + expiresIn = 60_000; + } + + const response = this.createResponsePromise(id); + const timeout = this.createTimeoutPromise(id, expiresIn); + + if (typeof callback === "function") { + try { + const ret = await Promise.race([response, timeout]); + + try { + callback(ret, undefined); + } catch (callbackError) { /* */ } + } catch (error) { + callback(undefined, error); + } + } else { + return Promise.race([response, timeout]); + } + } + + private createTimeoutPromise(id: number, expiresIn: number) { + return new Promise((resolve, reject) => { + setTimeout(() => { + this.ids.release(id); + delete this.callbacks[id]; + reject(new CodeError("Command timed out.", "ETIMEOUT", "CommandError")); + }, expiresIn); + }); + } + + private createResponsePromise(id: number) { + return new Promise((resolve, reject) => { + this.callbacks[id] = (error: Error | null, result?: any) => { + this.ids.release(id); + delete this.callbacks[id]; + + if (error) { + reject(error); + } else { + resolve(result); + } + } + }); + } + + close() { + return super.close(); + } +} diff --git a/packages/duplex/src/client/queue.ts b/packages/duplex/src/client/queue.ts new file mode 100644 index 0000000..a95c419 --- /dev/null +++ b/packages/duplex/src/client/queue.ts @@ -0,0 +1,51 @@ +export class QueueItem { + value: T; + private expiration: number; + + constructor(value: T, expiresIn: number) { + this.value = value; + this.expiration = Date.now() + expiresIn; + } + + get expiresIn() { + return this.expiration - Date.now(); + } + + get isExpired() { + return Date.now() > this.expiration; + } +} + +export class Queue { + private items: QueueItem[] = []; + + add(item: T, expiresIn: number) { + this.items.push(new QueueItem(item, expiresIn)); + } + + get isEmpty() { + let i = this.items.length; + + while (i--) { + if (this.items[i].isExpired) { + this.items.splice(i, 1); + } else { + return false; + } + } + + return true; + } + + pop(): QueueItem | null { + while (this.items.length) { + const item = this.items.shift(); + + if (!item.isExpired) { + return item; + } + } + + return null; + } +} diff --git a/packages/duplex/src/common/codeerror.ts b/packages/duplex/src/common/codeerror.ts new file mode 100644 index 0000000..ac0e062 --- /dev/null +++ b/packages/duplex/src/common/codeerror.ts @@ -0,0 +1,15 @@ +export class CodeError extends Error { + code: string; + name: string; + + constructor(message: string, code?: string, name?: string) { + super(message); + if (typeof code === "string") { + this.code = code; + } + if (typeof name === "string") { + this.name = name; + } + } +} + diff --git a/packages/duplex/src/common/command.ts b/packages/duplex/src/common/command.ts new file mode 100644 index 0000000..91b6162 --- /dev/null +++ b/packages/duplex/src/common/command.ts @@ -0,0 +1,25 @@ +interface CommandData { + id: number; + command: number; + payload: any; +} + +export class Command { + static toBuffer({ payload, id, command }: CommandData): Buffer { + if (payload === undefined) throw new TypeError("The payload must not be undefined!"); + const payloadString = JSON.stringify(payload); + const buffer = Buffer.allocUnsafe(payloadString.length + 3); + buffer.writeUInt16LE(id, 0); + buffer.writeUInt8(command, 2); + buffer.write(payloadString, 3); + return buffer; + } + + static parse(buffer: Buffer): CommandData { + if (buffer.length < 3) throw new TypeError(`Token too short! Expected at least 3 bytes, got ${buffer.length}!`); + const id = buffer.readUInt16LE(0); + const command = buffer.readUInt8(2); + const payload = JSON.parse(buffer.toString("utf8", 3)); + return { id, command, payload }; + } +} diff --git a/packages/duplex/src/common/connection.ts b/packages/duplex/src/common/connection.ts new file mode 100644 index 0000000..c698710 --- /dev/null +++ b/packages/duplex/src/common/connection.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from "node:events"; +import { Duplex } from "node:stream"; +import { Message, NEWLINE } from "./message"; + +const CLOSE_TOKEN = Buffer.from("\\\n"); + +export class Connection extends EventEmitter { + private readonly duplex: Duplex; + private buffer = Buffer.allocUnsafe(0); + + constructor(duplex: Duplex) { + super(); + this.duplex = duplex; + this.applyListeners(); + } + + private applyListeners() { + this.duplex.on("data", (buffer: Buffer) => { + this.buffer = Buffer.concat([this.buffer, buffer]); + this.parse(); + }); + + this.duplex.on("close", () => { + this.emit("close"); + }); + } + + private parse() { + while (this.buffer.length > 0) { + const i = this.buffer.indexOf(NEWLINE); + + if (i === -1) break; + + // +1 to include the separating newline. + const data = this.buffer.subarray(0, i + 1); + + + if (data.equals(CLOSE_TOKEN)) { + this.emit("remoteClose"); + } else { + this.emit("token", Message.unescape(data)); + } + + this.buffer = this.buffer.subarray(i + 1); + } + } + + get isDead() { + return !this.duplex.writable || !this.duplex.readable; + } + + send(buffer: Buffer) { + if (this.isDead) return false; + + this.duplex.write(Message.escape(buffer)); + return true; + } + + close() { + if (this.isDead) return false; + this.duplex.end(); + return true; + } + + remoteClose() { + if (this.isDead) return false; + this.duplex.write(CLOSE_TOKEN); + return true; + } +} diff --git a/packages/duplex/src/common/errorserializer.ts b/packages/duplex/src/common/errorserializer.ts new file mode 100644 index 0000000..8d9de71 --- /dev/null +++ b/packages/duplex/src/common/errorserializer.ts @@ -0,0 +1,37 @@ +export interface SerializedError { + name: string; + message: string; + stack: string; + [prop: string]: any; +} + +export class ErrorSerializer { + // Converts an Error into a standard object. + static serialize(error: Error): SerializedError { + const { message, name, stack } = error; + return { message, name, stack, ...error }; + } + + // Converts an object into an Error instance. + static deserialize (data: SerializedError) { + const Factory = this.getFactory(data); + + const error = new Factory(data.message); + Object.assign(error, data); + + return error; + } + + // Tries to find the global class for the error name and + // returns Error if none is found. + private static getFactory (data: SerializedError): new (message: string) => Error { + const name = data.name; + + if (name.endsWith("Error") && global[name]) { + return global[name]; + } + + return Error; + } +} + diff --git a/packages/duplex/src/common/message.ts b/packages/duplex/src/common/message.ts new file mode 100644 index 0000000..5afafcf --- /dev/null +++ b/packages/duplex/src/common/message.ts @@ -0,0 +1,65 @@ +export const NEWLINE = Buffer.from("\n")[0]; +const ESC = Buffer.from("\\")[0]; +const ESC_N = Buffer.from("n")[0]; + +export class Message { + // Escape all newlines and backslashes in a Buffer. + static escape(data: Buffer): Buffer { + const result: number[] = []; + + for (const char of data) { + switch (char) { + case ESC: + // Escape the escaped backslash + result.push(ESC); + result.push(ESC); + break; + case NEWLINE: + // Escape newline + result.push(ESC); + result.push(ESC_N); + break; + default: + result.push(char); + break; + } + } + + result.push(NEWLINE); + + return Buffer.from(result); + } + + // Undoes what the escape method does. + static unescape(data: Buffer): Buffer { + const result: number[] = []; + + // Ignore last byte because it's the separating newline. + for (let i = 0; i < data.length - 1; i++) { + const char = data[i]; + const next = data[i + 1]; + + if (char === ESC) { + switch (next) { + case ESC: + // Escaped escaped backslash. + result.push(ESC); + i += 1; + break; + case ESC_N: + // Escaped newline. + result.push(NEWLINE); + i += 1; + break; + default: + throw new Error("Unescaped backslash detected!"); + } + } else { + result.push(char); + } + } + + return Buffer.from(result); + } +} + diff --git a/packages/duplex/src/common/status.ts b/packages/duplex/src/common/status.ts new file mode 100644 index 0000000..20820f9 --- /dev/null +++ b/packages/duplex/src/common/status.ts @@ -0,0 +1,6 @@ +export enum Status { + ONLINE = 3, + CONNECTING = 2, + CLOSED = 1, + OFFLINE = 0, +} diff --git a/packages/duplex/src/example/client.ts b/packages/duplex/src/example/client.ts new file mode 100644 index 0000000..158dc44 --- /dev/null +++ b/packages/duplex/src/example/client.ts @@ -0,0 +1,27 @@ +import { CommandClient } from "../client/commandclient"; +import { CodeError } from "../common/codeerror"; + +const client = new CommandClient({ + host: "localhost", + port: 3351, + secure: false, +}); + +const payload = { things: "stuff", numbers: [1, 2, 3] }; + +async function main() { + const callback = (result: any, error: CodeError) => { + if (error) { + console.log("ERR [0]", error.code); + return; + } + + console.log("RECV [0]", result); + client.close(); + }; + + client.command(0, payload, 10, callback); + +} + +main(); diff --git a/packages/duplex/src/example/server.ts b/packages/duplex/src/example/server.ts new file mode 100644 index 0000000..d4b5a83 --- /dev/null +++ b/packages/duplex/src/example/server.ts @@ -0,0 +1,18 @@ +import { CodeError } from "../common/codeerror"; +import { Connection } from "../common/connection"; +import { CommandServer } from "../server/commandserver"; + +const server = new CommandServer({ + host: "localhost", + port: 3351, + secure: false, +}); + +server.command(0, async (payload: any, connection: Connection) => { + console.log("RECV [0]:", payload); + return { ok: "OK" }; +}); + +server.on("clientError", (error: CodeError) => { + console.log("clientError", error.code); +}); diff --git a/packages/duplex/src/index.ts b/packages/duplex/src/index.ts new file mode 100644 index 0000000..f6a35aa --- /dev/null +++ b/packages/duplex/src/index.ts @@ -0,0 +1,5 @@ +export { CommandClient, type TokenClientOptions } from "./client/commandclient"; +export { CommandServer, type TokenServerOptions } from "./server/commandserver"; +export { Connection } from "./common/connection"; +export { CodeError } from "./common/codeerror"; +export { Status } from "./common/status"; diff --git a/packages/duplex/src/server/commandserver.ts b/packages/duplex/src/server/commandserver.ts new file mode 100644 index 0000000..2c89fad --- /dev/null +++ b/packages/duplex/src/server/commandserver.ts @@ -0,0 +1,179 @@ +import { EventEmitter } from "node:events"; +import net, { Socket } from "node:net"; +import tls from "node:tls"; +import { CodeError } from "../common/codeerror"; +import { Command } from "../common/command"; +import { Connection } from "../common/connection"; +import { ErrorSerializer } from "../common/errorserializer"; +import { Status } from "../common/status"; + +export type TokenServerOptions = tls.TlsOptions & net.ListenOptions & net.SocketConstructorOpts & { + secure?: boolean; +}; + +export class TokenServer extends EventEmitter { + connections: Connection[] = []; + + public options: TokenServerOptions; + public server: tls.Server | net.Server; + private hadError: boolean; + + status: Status; + + constructor(options: TokenServerOptions) { + super(); + + this.options = options; + + if (this.options.secure) { + this.server = tls.createServer(this.options, function (clientSocket) { + clientSocket.on("error", (err) => { + this.emit("clientError", err); + }); + }) + } else { + this.server = net.createServer(this.options, function (clientSocket) { + clientSocket.on("error", (err) => { + this.emit("clientError", err); + }); + }); + } + + this.applyListeners(); + this.connect(); + } + + connect(callback?: () => void) { + if (this.status >= Status.CONNECTING) return false; + + this.hadError = false; + this.status = Status.CONNECTING; + this.server.listen(this.options, () => { + if (callback) callback(); + }); + return true; + } + + close(callback?: () => void) { + if (!this.server.listening) return false; + + this.status = Status.CLOSED; + this.server.close(() => { + for (const connection of this.connections) { + connection.remoteClose(); + } + if (callback) callback(); + }); + + return true; + } + + applyListeners() { + this.server.on("listening", () => { + this.status = Status.ONLINE; + this.emit("listening"); + }); + + this.server.on("tlsClientError", (error) => { + this.emit("clientError", error); + }); + + this.server.on("clientError", (error) => { + this.emit("clientError", error); + }); + + this.server.on("error", (error) => { + this.hadError = true; + this.emit("error", error); + this.server.close(); + }); + + this.server.on("close", () => { + this.status = Status.OFFLINE; + this.emit("close", this.hadError); + }); + + this.server.on("secureConnection", (socket: Socket) => { + const connection = new Connection(socket); + this.connections.push(connection); + + connection.once("close", () => { + const i = this.connections.indexOf(connection); + if (i !== -1) this.connections.splice(i, 1); + }); + + connection.on("token", (token) => { + this.emit("token", token, connection); + }); + }); + + this.server.on("connection", (socket: Socket) => { + if (this.options.secure) return; + + const connection = new Connection(socket); + this.connections.push(connection); + + connection.once("close", () => { + const i = this.connections.indexOf(connection); + if (i !== -1) this.connections.splice(i, 1); + }); + + connection.on("token", (token) => { + this.emit("token", token, connection); + }); + }); + } +} + +type CommandFn = (payload: any, connection: Connection) => Promise; + +export class CommandServer extends TokenServer { + private commands: { + [command: number]: CommandFn + } = {}; + + constructor(options: TokenServerOptions) { + super(options); + this.init(); + } + + private init() { + this.on("token", async (buffer, connection) => { + try { + const { id, command, payload } = Command.parse(buffer); + this.runCommand(id, command, payload, connection); + } catch (error) { + this.emit("error", error); + } + }); + } + + /** + * @param command - The command number to register, a UInt8 (0-255). + * 255 is reserved. You will get an error if you try to use it. + * @param fn - The function to run when the command is received. + */ + command(command: number, fn: CommandFn) { + this.commands[command] = fn; + } + + private async runCommand(id: number, command: number, payload: any, connection: Connection) { + try { + if (!this.commands[command]) { + throw new CodeError(`Command (${command}) not found.`, "ENOTFOUND", "CommandError"); + } + + const result = await this.commands[command](payload, connection); + + // A payload should not be undefined, so if a command returns nothing + // we respond with a simple "OK". + const payloadResult = result === undefined ? "OK" : result; + + connection.send(Command.toBuffer({ command, id, payload: payloadResult })); + } catch (error) { + const payload = ErrorSerializer.serialize(error); + + connection.send(Command.toBuffer({ command: 255, id, payload })); + } + } +} diff --git a/packages/duplex/src/server/ids.ts b/packages/duplex/src/server/ids.ts new file mode 100644 index 0000000..ca1cbae --- /dev/null +++ b/packages/duplex/src/server/ids.ts @@ -0,0 +1,40 @@ +export class IdManager { + ids: Array = []; + index: number = 0; + maxIndex: number; + + constructor(maxIndex: number = 2 ** 16 - 1) { + this.maxIndex = maxIndex; + } + + release(id: number) { + if (id < 0 || id > this.maxIndex) { + throw new TypeError(`ID must be between 0 and ${this.maxIndex}. Got ${id}.`); + } + this.ids[id] = false; + } + + reserve(): number { + const startIndex = this.index; + + while (true) { + const i = this.index; + + if (!this.ids[i]) { + this.ids[i] = true; + + return i; + } + + if (this.index >= this.maxIndex) { + this.index = 0; + } else { + this.index++; + } + + if (this.index === startIndex) { + throw new Error(`All IDs are reserved. Make sure to release IDs when they are no longer used.`); + } + } + } +} diff --git a/packages/duplex/tsconfig.json b/packages/duplex/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/duplex/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/duplex/tsup.config.ts b/packages/duplex/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/duplex/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/express-keepalive-ws/.npmignore b/packages/express-keepalive-ws/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/express-keepalive-ws/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/express-keepalive-ws/README.md b/packages/express-keepalive-ws/README.md new file mode 100644 index 0000000..fff6e6d --- /dev/null +++ b/packages/express-keepalive-ws/README.md @@ -0,0 +1,43 @@ +# @prsm/express-keepalive-ws + +This is a middleware that creates and exposes a `KeepAliveServer` instance (see [prsm/keepalive-ws](https://github.com/...). + +```typescript +import express from "express"; +import createWss, { type WSContext } from "@prsm/express-keepalive-ws"; + +const app = express(); +const server = createServer(app); + +const { middleware: ws, wss } = createWss({ /** ... */ }); + +app.use(ws); + +// as a middleware: +app.use("/ws", async (req, res) => { + if (req.ws) { // <-- req.ws will be defined if the request is a WebSocket request + const ws = await req.ws(); // handle the upgrade and receive the client WebSocket + ws.send("Hello WS!"); // send a message to the client + } else { + res.send("Hello HTTP!"); + } +}); + +// as a command server: +wss.registerCommand("echo", (c: WSContext) => { + const { payload } = c; + return `echo: ${payload}`; +}); +``` + +Client-side usage (more at https://github.com/node-prism/keepalive-ws): + +```typescript +import { KeepAliveClient } from "@prsm/keepalive-ws/client"; + +const opts = { shouldReconnect: true }; +const ws = new KeepAliveClient("ws://localhost:PORT", opts); + +const echo = await ws.command("echo", "hello!"); +console.log(echo); // "echo: hello!" +``` diff --git a/packages/express-keepalive-ws/bump.config.ts b/packages/express-keepalive-ws/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/express-keepalive-ws/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/express-keepalive-ws/bun.lockb b/packages/express-keepalive-ws/bun.lockb new file mode 100755 index 0000000..8a74d16 Binary files /dev/null and b/packages/express-keepalive-ws/bun.lockb differ diff --git a/packages/express-keepalive-ws/package.json b/packages/express-keepalive-ws/package.json new file mode 100644 index 0000000..0c48c99 --- /dev/null +++ b/packages/express-keepalive-ws/package.json @@ -0,0 +1,30 @@ +{ + "name": "@prsm/express-keepalive-ws", + "version": "1.2.0", + "author": "", + "main": "./dist/index.js", + "devDependencies": { + "@types/ws": "^8.5.12", + "bumpp": "^9.5.1", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "description": "", + "keywords": [], + "license": "Apache-2.0", + "scripts": { + "build": "tsup", + "release": "bumpp package.json && npm publish --access public" + }, + "type": "module", + "dependencies": { + "@prsm/keepalive-ws": "^0.3.1" + } +} diff --git a/packages/express-keepalive-ws/src/index.ts b/packages/express-keepalive-ws/src/index.ts new file mode 100644 index 0000000..2607d94 --- /dev/null +++ b/packages/express-keepalive-ws/src/index.ts @@ -0,0 +1,65 @@ +import { + KeepAliveServer, + type KeepAliveServerOptions, +} from "@prsm/keepalive-ws/server"; +import { type Server } from "node:http"; +import { STATUS_CODES } from "node:http"; + +const createWsMiddleware = ( + server: Server, + options: KeepAliveServerOptions = {}, +): { middleware: (req, res, next) => Promise; wss: KeepAliveServer } => { + const wss = new KeepAliveServer({ ...options, noServer: true }); + + server.on("upgrade", (request, socket, head) => { + const { pathname } = new URL(request.url, `http://${request.headers.host}`); + + const path = options.path || "/"; + + if (pathname !== path) { + socket.write( + [ + `HTTP/1.0 400 ${STATUS_CODES[400]}`, + "Connection: close", + "Content-Type: text/html", + `Content-Length: ${Buffer.byteLength(STATUS_CODES[400])}`, + "", + STATUS_CODES[400], + ].join("\r\n"), + ); + + socket.destroy(); + + return; + } + + wss.handleUpgrade(request, socket, head, (client, req) => { + wss.emit("connection", client, req); + }); + }); + + const middleware = async (req, res, next) => { + const upgradeHeader: string[] = + req.headers.upgrade + ?.toLowerCase() + .split(",") + .map((s) => s.trim()) || []; + + if (upgradeHeader.includes("websocket")) { + req.ws = () => + new Promise((resolve) => { + wss.handleUpgrade(req, req.socket, Buffer.alloc(0), (client) => { + wss.emit("connection", client, req); + resolve(client); + }); + }); + } + + await next(); + }; + + return { middleware, wss }; +}; + +export default createWsMiddleware; +export { type WSContext } from "@prsm/keepalive-ws/server"; diff --git a/packages/express-keepalive-ws/tsconfig.json b/packages/express-keepalive-ws/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/express-keepalive-ws/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/express-keepalive-ws/tsup.config.ts b/packages/express-keepalive-ws/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/express-keepalive-ws/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/express-session-auth/.npmignore b/packages/express-session-auth/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/express-session-auth/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/express-session-auth/README.md b/packages/express-session-auth/README.md new file mode 100644 index 0000000..cfacac3 --- /dev/null +++ b/packages/express-session-auth/README.md @@ -0,0 +1,86 @@ +# express-session-auth + +## Requirements + +- `express-session`: https://github.com/expressjs/session +- `cookie-parser`: https://github.com/expressjs/cookie-parser +- TypeORM + - `express-session-auth` exports entities (`User`, `UserReset`, `UserRemember`, `UserConfirmation`) that you need to include in your datasource for migration/sync purposes. + + +## Quickstart + +Wherever you create your express application, include the auth middleware and pass in your TypeORM datasource. + +```typescript +import express from "express"; +import { createServer } from "node:http"; +import auth from "@prsm/express-session-auth"; +import datasource from "./my-datasource"; + +const app = express(); +const server = createServer(app); + +// the auth middleware needs your datasource instance +app.use(auth({ datasource })); +``` + +Here's an example TypeORM datasource: + +```typescript +// my-datasource.ts +import { + User, + UserConfirmation, + UserRemember, + UserReset, +} from "@prsm/express-session-auth"; +import { DataSource } from "typeorm"; + +const datasource = new DataSource({ + type: "mysql", // express-session-auth supports mysql, postgres and sqlite (others not tested) + host: process.env.DB_HOST, + port: process.env.DB_PORT ? +process.env.DB_PORT : 3306, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [ + User, + UserConfirmation, + UserRemember, + UserReset, + /* the reset of your entities here */ + ], +}); + +export default datasource; +``` + +Environment variables and their defaults: + +```bash +HTTP_PORT=3002 + +AUTH_SESSION_REMEMBER_DURATION=30d +AUTH_SESSION_REMEMBER_COOKIE_NAME=prsm.auth.remember +AUTH_SESSION_RESYNC_INTERVAL=30m +AUTH_MINIMUM_PASSWORD_LENGTH=8 +AUTH_MAXIMUM_PASSWORD_LENGTH=64 + +DB_HOST=localhost +DB_PORT=3306 +DB_USERNAME=root +DB_PASSWORD=toor +DB_NAME=prsm +``` + +Because this middleware augments the `Request` object by adding an `auth` property, you will want to add the following to your `tsconfig.json` so that your language server doesn't flag references to `req.auth` as an error: + +```json +{ + "include": [ + "src", + "node_modules/@prsm/express-session-auth/express-session-auth.d.ts" + ] +} +``` diff --git a/packages/express-session-auth/bump.config.ts b/packages/express-session-auth/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/express-session-auth/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/express-session-auth/bun.lockb b/packages/express-session-auth/bun.lockb new file mode 100755 index 0000000..1b5b011 Binary files /dev/null and b/packages/express-session-auth/bun.lockb differ diff --git a/packages/express-session-auth/express-session-auth.d.ts b/packages/express-session-auth/express-session-auth.d.ts new file mode 100644 index 0000000..864dd0d --- /dev/null +++ b/packages/express-session-auth/express-session-auth.d.ts @@ -0,0 +1,10 @@ +import { createAuth, createAuthAdmin } from "./src/middleware.js"; + +declare global { + namespace Express { + interface Request { + auth: Awaited>; + authAdmin: ReturnType; + } + } +} diff --git a/packages/express-session-auth/package.json b/packages/express-session-auth/package.json new file mode 100644 index 0000000..a922ac7 --- /dev/null +++ b/packages/express-session-auth/package.json @@ -0,0 +1,46 @@ +{ + "name": "@prsm/express-session-auth", + "version": "1.5.0", + "description": "", + "main": "./dist/index.js", + "type": "module", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "express-session-auth.d.ts" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "release": "bumpp package.json && npm publish --access public" + }, + "keywords": [], + "author": "", + "peerDependencies": { + "typeorm": "0.3.20" + }, + "license": "Apache-2.0", + "dependencies": { + "@prsm/hash": "^1.0.2", + "@prsm/ids": "^1.1.1", + "@prsm/ms": "^1.0.1", + "@types/express": "^4.17.21", + "cookie-parser": "^1.4.6", + "express": "^4.19.2", + "express-session": "^1.18.0" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/express-session": "^1.18.0", + "@types/node": "^22.4.1", + "bumpp": "^9.5.1", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + } +} diff --git a/packages/express-session-auth/src/errors.ts b/packages/express-session-auth/src/errors.ts new file mode 100644 index 0000000..882c076 --- /dev/null +++ b/packages/express-session-auth/src/errors.ts @@ -0,0 +1,118 @@ +export class ConfirmationNotFoundError extends Error { + constructor(message: string = "Confirmation selector/token pair not found") { + super(message); + this.name = "ConfirmationNotFoundError"; + } +} + +export class ConfirmationExpiredError extends Error { + constructor(message: string = "Confirmation selector/token pair expired") { + super(message); + this.name = "ConfirmationExpiredError"; + } +} + +export class EmailTakenError extends Error { + constructor(message: string = "Email already exists") { + super(message); + this.name = "EmailTakenError"; + } +} + +export class EmailNotVerifiedError extends Error { + constructor(message: string = "User not verified") { + super(message); + this.name = "EmailNotVerifiedError"; + } +} + +export class ImpersonationNotAllowedError extends Error { + constructor(message: string = "Impersonation not allowed") { + super(message); + this.name = "ImpersonationNotAllowedError"; + } +} + +export class InvalidEmailError extends Error { + constructor(message: string = "Invalid email provided") { + super(message); + this.name = "InvalidEmailError"; + } +} + +export class InvalidPasswordError extends Error { + constructor(message: string = "Invalid password provided") { + super(message); + this.name = "InvalidPasswordError"; + } +} + +export class InvalidTokenError extends Error { + constructor(message: string = "Invalid selector/token pair provided") { + super(message); + this.name = "InvalidSelectorTokenPairError"; + } +} + +export class InvalidUsernameError extends Error { + constructor(message: string = "Invalid username provided") { + super(message); + this.name = "InvalidUsernameError"; + } +} + +export class ResetDisabledError extends Error { + constructor(message: string = "Password reset is disabled") { + super(message); + this.name = "ResetDisabledError"; + } +} + +export class ResetExpiredError extends Error { + constructor(message: string = "Reset request expired") { + super(message); + this.name = "ResetExpiredError"; + } +} + +export class ResetNotFoundError extends Error { + constructor(message: string = "Reset request not found") { + super(message); + this.name = "ResetNotFoundError"; + } +} + +export class TooManyResetsError extends Error { + constructor(message: string = "Too many resets") { + super(message); + this.name = "TooManyResetsError"; + } +} + +export class UserInactiveError extends Error { + constructor(message: string = "User is inactive") { + super(message); + this.name = "UserInactiveError"; + } +} + +export class UserNotFoundError extends Error { + constructor(message: string = "User not found") { + super(message); + this.name = "UserNotFoundError"; + } +} + +export class UserNotLoggedInError extends Error { + constructor(message: string = "User not logged in") { + super(message); + this.name = "UserNotLoggedInError"; + } +} + +export class UsernameTakenError extends Error { + constructor(message: string = "Username already exists") { + super(message); + this.name = "UsernameTakenError"; + } +} diff --git a/packages/express-session-auth/src/index.ts b/packages/express-session-auth/src/index.ts new file mode 100644 index 0000000..d33bd8b --- /dev/null +++ b/packages/express-session-auth/src/index.ts @@ -0,0 +1,5 @@ +export { middleware as default } from "./middleware.js"; +export { User, AuthStatus, AuthRole } from "./user.entity.js"; +export { UserReset } from "./user-reset.entity.js"; +export { UserConfirmation } from "./user-confirmation.entity.js"; +export { UserRemember } from "./user-remember.entity.js"; diff --git a/packages/express-session-auth/src/middleware.ts b/packages/express-session-auth/src/middleware.ts new file mode 100644 index 0000000..81eb571 --- /dev/null +++ b/packages/express-session-auth/src/middleware.ts @@ -0,0 +1,1489 @@ +import { hash } from "@prsm/hash"; +import ID from "@prsm/ids"; +import ms from "@prsm/ms"; +import type { Request, Response } from "express"; +import { LessThanOrEqual, MoreThanOrEqual, type DataSource } from "typeorm"; +import { + ConfirmationExpiredError, + ConfirmationNotFoundError, + EmailNotVerifiedError, + EmailTakenError, + InvalidEmailError, + InvalidPasswordError, + InvalidTokenError, + InvalidUsernameError, + ResetDisabledError, + ResetExpiredError, + ResetNotFoundError, + TooManyResetsError, + UserInactiveError, + UsernameTakenError, + UserNotFoundError, + UserNotLoggedInError, +} from "./errors.js"; +import { UserConfirmation } from "./user-confirmation.entity.js"; +import { UserRemember } from "./user-remember.entity.js"; +import { UserReset } from "./user-reset.entity.js"; +import { AuthStatus, getRoleMap, getStatusMap, User } from "./user.entity.js"; +import { ensureRequiredMiddlewares, isValidEmail } from "./util.js"; + +declare module "express-session" { + export interface SessionData { + auth: AuthSession; + } +} + +interface AuthenticatedRequest extends Request { + auth: Awaited>; + authAdmin: ReturnType; +} + +type AuthSession = { + loggedIn: boolean; + userId: string; + email: string; + username: string; + status: number; + rolemask: number; + remembered: boolean; + lastResync: Date; + forceLogout: number; + verified: boolean; +}; + +type ReqResDatasource = { + req: Request; + res: Response; + datasource: DataSource; +}; + +type TokenCallback = (token: string) => void; + +type CreateUserOptions = { + requireUsername: boolean; + email: string; + password: string; + username?: string; + callback?: TokenCallback; +}; + +const validateEmail = (email: string) => { + if (typeof email !== "string") { + throw new InvalidEmailError(); + } + if (!email.trim()) { + throw new InvalidEmailError(); + } + if (!isValidEmail(email)) { + throw new InvalidEmailError(); + } +}; + +const validatePassword = (password: string) => { + const minLength = process.env.AUTH_MINIMUM_PASSWORD_LENGTH + ? +process.env.AUTH_MINIMUM_PASSWORD_LENGTH + : 8; + + const maxLength = process.env.AUTH_MAXIMUM_PASSWORD_LENGTH + ? +process.env.AUTH_MAXIMUM_PASSWORD_LENGTH + : 64; + + if (typeof password !== "string") { + throw new InvalidPasswordError(); + } + + if (password.length < minLength) { + throw new InvalidPasswordError(); + } + + if (password.length > maxLength) { + throw new InvalidPasswordError(); + } +}; + +const createUserManager = ({ req, res, datasource }: ReqResDatasource) => { + const userRepository = () => datasource.getRepository(User); + const userConfirmationRepository = () => + datasource.getRepository(UserConfirmation); + const userRememberRepository = () => datasource.getRepository(UserRemember); + const userResetRepository = () => datasource.getRepository(UserReset); + + const getByUsername = (username: string) => + userRepository().findOne({ where: { username } }); + const getByEmail = (email: string) => + userRepository().findOne({ where: { email } }); + const getById = (id: number) => userRepository().findOne({ where: { id } }); + + /** + * Operates on session.auth. + */ + const hasRole = async (role: number) => { + if (req.session.auth) { + return (req.session.auth.rolemask & role) === role; + } + + const user = await getUser(); + return (user.rolemask & role) === role; + }; + + /** + * Operates on session.auth. + */ + const isRemembered = () => req.session.auth?.remembered ?? false; + + /** + * Operates on session.auth. + */ + const isAdmin = async () => hasRole(1); + + const getSessionProperty = (property: PropertyKey) => { + return req.session?.auth ? req.session.auth[property] : null; + }; + + /** Returns the logged-in user's `id` property. */ + const getId = () => + getSessionProperty("userId") + ? ID.decode(getSessionProperty("userId")) + : null; + + /** + * Operates on session.auth. + * Returns the logged-in user's `email` property. + */ + const getEmail = () => getSessionProperty("email"); + + /** + * Operates on session.auth. + * Returns the logged-in user's `status` property. + */ + const getStatus = (): number => getSessionProperty("status"); + + /** + * Operates on session.auth. + * Returns the logged-in user's `verified` property. + */ + const getVerified = (): number => getSessionProperty("verified"); + + /** + * Operates on session.auth. + * Returns the logged-in user. + */ + const getUser = async () => { + const userId = getId(); + + if (!userId) { + return null; + } + + const user = await userRepository().findOne({ where: { id: userId } }); + + if (!user) { + return null; + } + + return user; + }; + + /** + * Operates on session.auth. + */ + const getRoleNames = (rolemask?: number) => { + const mask = + rolemask === undefined ? getSessionProperty("rolemask") : rolemask; + + if (!mask && mask !== 0) { + return []; + } + + return Object.entries(getRoleMap()) + .filter(([key, value]) => mask & parseInt(key)) + .map(([key, value]) => value); + }; + + /** + * Operates on session.auth. + */ + const getStatusName = () => { + const status = getStatus(); + return getStatusMap()[status]; + }; + + const createUserInternal = async ({ + requireUsername, + email, + password, + username, + callback, + }: CreateUserOptions) => { + validateEmail(email); + validatePassword(password); + + const trimmedUsername = username?.trim(); + + if (trimmedUsername === "") { + throw new InvalidUsernameError(); + } + + if (requireUsername && trimmedUsername) { + const existingUser = await getByUsername(username); + + if (existingUser) { + throw new UsernameTakenError(); + } + } + + const existingUser = await userRepository().findOne({ where: { email } }); + + if (existingUser) { + throw new EmailTakenError(); + } + + const hashedPassword = hash.encode(password); + const verified = typeof callback !== "function"; + + const user = userRepository().create({ + email, + password: hashedPassword, + username: trimmedUsername, + verified, + status: AuthStatus.Normal, + resettable: true, + rolemask: 0, + registered: new Date(), + lastLogin: null, + forceLogout: 0, + }); + + await userRepository().save(user); + + if (!verified) { + await createConfirmationToken(user, email, callback); + } + + return user; + }; + + const createConfirmationToken = async ( + user: User, + email: string, + callback: TokenCallback, + ) => { + const token = hash.encode(email); + const expires = new Date( + Date.now() + 1000 * 60 * 60 * 24 * 7, // 1 week + ); + + await userConfirmationRepository().delete({ user }); + + const confirmation = userConfirmationRepository().create({ + user, + token, + expires, + email, + }); + + await userConfirmationRepository().save(confirmation); + + if (callback) { + callback(token); + } + }; + + const recreateConfirmationTokenForUserId = async ( + userId: number, + callback: TokenCallback, + ) => { + const user = await getById(userId); + + if (!user) { + throw new UserNotFoundError(); + } + + return recreateConfirmationToken(user, callback); + }; + + const recreateConfirmationTokenForEmail = async ( + email: string, + callback: TokenCallback, + ) => { + const user = await getByEmail(email); + + if (!user) { + throw new UserNotFoundError(); + } + + return recreateConfirmationToken(user, callback); + }; + + const recreateConfirmationToken = async ( + user: User, + callback: TokenCallback, + ) => { + const latestAttempt = await userConfirmationRepository().findOne({ + where: { user }, + order: { expires: "DESC" }, + }); + + if (!latestAttempt) { + throw new ConfirmationNotFoundError(); + } + + await createConfirmationToken(user, latestAttempt.email, callback); + }; + + const createRememberDirective = async (user: User) => { + const token = hash.encode(user.email); + const expires = new Date( + Date.now() + ms(process.env.AUTH_SESSION_REMEMBER_DURATION), + ); + + await userRememberRepository().delete({ user }); + + await userRememberRepository().insert({ + user, + token, + expires, + }); + + setRememberCookie(token, expires); + + return token; + }; + + const setRememberCookie = (token: string, expires: Date) => { + const cookieName = process.env.AUTH_SESSION_REMEMBER_COOKIE_NAME; + const cookieOptions = { expires, httpOnly: true, secure: false }; + res.cookie(cookieName, token, cookieOptions); + }; + + /** + * Registers a new user with the provided email, password, and optional username. + * + * - When a callback is provided, the user's `verified` property will be set to `0` and a confirmation token will be created. + * The token will be passed to the callback. You should email the token to the user and have a route that accepts + * the token and then calls `confirmEmail` or `confirmEmailAndLogin` with it. + * + * @throws {InvalidEmailError} When the provided email is invalid. + * @throws {InvalidPasswordError} When the provided password is invalid. + * @throws {EmailTakenError} When the provided email is already in use. + * @throws {InvalidUsernameError} When the provided username is invalid. + */ + const register = async ( + email: string, + password: string, + username?: string, + callback?: TokenCallback, + ) => + createUserInternal({ + requireUsername: false, + email, + password, + username, + callback, + }); + + const registerWithUniqueUsername = async ( + email: string, + password: string, + username: string, + callback: TokenCallback, + ) => + createUserInternal({ + requireUsername: true, + email, + password, + username, + callback, + }); + + return { + register, + registerWithUniqueUsername, + + getId, + getEmail, + getStatus, + getVerified, + getRoleNames, + getStatusName, + + getUser, + + getById, + getByEmail, + getByUsername, + + userRepository, + userResetRepository, + userConfirmationRepository, + userRememberRepository, + + setRememberCookie, + createRememberDirective, + + createConfirmationToken, + recreateConfirmationTokenForEmail, + recreateConfirmationTokenForUserId, + + isAdmin, + hasRole, + isRemembered, + }; +}; + +export const createAuth = async ({ + req, + res, + datasource, +}: ReqResDatasource) => { + if (!datasource) { + throw new Error("datasource is required"); + } + const um = createUserManager({ req, res, datasource }); + + const isLoggedIn = () => req.session?.auth?.loggedIn ?? false; + + /** + * Resynchronizes the session with the latest user data. + * + * - Does nothing if the user is not logged in. + * - Resynchronizes only if the last resync was before the configured interval. + * - Logs out the user if the user cannot be found. + * - Logs out the user if the forceLogout value in the database is greater than the session's forceLogout value. + * + * @throws {Error} When session regeneration fails. + */ + const resyncSession = async () => { + if (!isLoggedIn()) { + return; + } + + const interval = ms(process.env.AUTH_SESSION_RESYNC_INTERVAL || "30m"); + + const lastResync = new Date(req.session.auth.lastResync); + + if (lastResync && lastResync.getTime() > Date.now() - interval) { + return; + } + + const user = await um.getUser(); + + if (!user) { + await logout(); + return; + } + + if (user.forceLogout > req.session.auth.forceLogout) { + await logout(); + return; + } + + req.session.auth.email = user.email; + req.session.auth.username = user.username; + req.session.auth.status = user.status; + req.session.auth.rolemask = user.rolemask; + req.session.auth.verified = user.verified; + req.session.auth.lastResync = new Date(); + }; + + await resyncSession(); + + const processRememberDirective = async () => { + if (!isLoggedIn()) { + return; + } + + const { token } = getRememberToken(); + + if (!token) { + return; + } + + const directive = await um + .userRememberRepository() + .findOne({ where: { token } }); + + if (!directive) { + return; + } + + if (!directive.user) { + await logout(); + return; + } + + // remove expired directives for this user + const expiredRemembers = await um.userRememberRepository().find({ + where: { user: directive.user, expires: LessThanOrEqual(new Date()) }, + }); + await um.userRememberRepository().remove(expiredRemembers); + + // is this directive expired? + if (new Date() > directive.expires) { + await um.userRememberRepository().remove(directive); + um.setRememberCookie(null, new Date(0)); + return; + } + + // okay to login + await onLoginSuccessful(directive.user, true); + }; + + /** + * Logs in a user with the provided email and password. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided email. + * @throws {InvalidPasswordError} When the provided password is incorrect. + * @throws {EmailNotVerifiedError} When the user's email is not verified. + * @throws {UserInactiveError} When the user's status is not normal. + */ + const login = async (email: string, password: string, remember = false) => + loginWithCredentials({ email, password, remember }); + + const loginWithCredentials = async (credentials: { + email: string; + password: string; + username?: string; + remember: boolean; + }) => { + const user = credentials.email + ? await um.getByEmail(credentials.email) + : await um.getByUsername(credentials.username); + + if (!user) { + throw new UserNotFoundError(); + } + + if (!hash.verify(user.password, credentials.password)) { + throw new InvalidPasswordError(); + } + + if (!user.verified) { + throw new EmailNotVerifiedError(); + } + + if (user.status !== AuthStatus.Normal) { + throw new UserInactiveError(); + } + + await onLoginSuccessful(user, credentials.remember); + }; + + /** + * Logs out the currently logged-in user. + * + * - Deletes the remember token if it exists. + * - Clears the remember cookie. + * + * @throws {Error} When session regeneration fails. + */ + const logout = async () => { + if (!isLoggedIn()) { + return; + } + + const { token } = getRememberToken(); + + if (token) { + await um.userRememberRepository().delete({ token }); + um.setRememberCookie(null, new Date(0)); + } + + req.session.auth = undefined; + }; + + /** + * Forces logout for a user identified by id. + * + * - Increments the forceLogout counter for the user. + * + * @throws {TypeError} When the provided id is not a number. + */ + const forceLogoutForUserById = async (id: number) => { + if (typeof id !== "number") { + throw new TypeError("User ID must be a number"); + } + + await um.userRememberRepository().delete({ user: { id } }); + await um.userRepository().increment({ id }, "forceLogout", 1); + }; + + /** + * Forces logout for the currently logged-in user. + */ + const forceLogoutForUser = async () => { + const userId = um.getId(); + + if (userId) { + await forceLogoutForUserById(userId); + } + }; + + /** + * Logs out the user from all sessions except the current one. + * + * - Increments the forceLogout counter for the user. + * - Regenerates the session to apply the forceLogout change. + * + * Since this session's forceLogout value will not be greater than the + * value in the database, the user will be logged out from all other sessions, + * but not from the current one. See resyncSession for clarity on this behavior. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const logoutEverywhereElse = async () => { + if (!isLoggedIn()) { + return; + } + + const userId = um.getId(); + + const user = await um.getById(userId); + + if (!user) { + await logout(); + return; + } + + await forceLogoutForUserById(userId); + + req.session.auth.forceLogout += 1; + + await regenerate(); + }; + + /** + * Logs out the user from all sessions, including the current one. + * + * - Calls `logoutEverywhereElse` to log out from all other sessions. + * - Calls `logout` to log out from the current session. + * + * @throws {Error} When session regeneration fails. + */ + const logoutEverywhere = async () => { + if (!isLoggedIn()) { + return; + } + + await logoutEverywhereElse(); + await logout(); + }; + + /** + * Regenerates the session while preserving the current auth data. + * + * - Copies the current session's auth data before regenerating the session. + * - Restores the auth data to the new session. + * + * @throws {Error} When session regeneration fails. + */ + const regenerate = async () => { + const auth = { ...req.session.auth }; + + return new Promise((resolve, reject) => { + req.session.regenerate((err) => { + if (err) { + reject(err); + return; + } + req.session.auth = auth; + resolve(); + }); + }); + }; + + const getRememberToken = () => { + if (!req.cookies) { + return { token: null }; + } + + const cookieName = process.env.AUTH_SESSION_REMEMBER_COOKIE_NAME; + const token = req.cookies[cookieName]; + + if (!token) { + return { token: null }; + } + + return { token }; + }; + + const getRememberExpiry = async () => { + if (!isLoggedIn()) { + return; + } + + const { token } = getRememberToken(); + + if (!token) { + return null; + } + + const directive = await um + .userRememberRepository() + .findOne({ where: { token } }); + + return directive?.expires ?? null; + }; + + /** + * Handles successful login for a user. + * + * - Updates the user's last login timestamp. + * - Regenerates the session to prevent session fixation attacks. + * - Sets the session's auth data with user details. + * - Creates a remember directive if the remember option is true. + * + * @throws {Error} When session regeneration fails. + */ + const onLoginSuccessful = async (user: User, remember = false) => { + await um.userRepository().update(user.id, { lastLogin: new Date() }); + + return new Promise((resolve, reject) => { + if (!req.session?.regenerate) { + console.log( + "COULD NOT REGENERATE SESSION WTF. req:session:", + req.session, + ); + resolve(); + } + req.session.regenerate(async (err) => { + if (err) { + reject(err); + return; + } + + const session: AuthSession = { + loggedIn: true, + userId: ID.encode(user.id), + email: user.email, + username: user.username, + status: user.status, + rolemask: user.rolemask, + remembered: remember, + lastResync: new Date(), + forceLogout: user.forceLogout, + verified: user.verified, + }; + + req.session.auth = session; + + if (remember) { + await um.createRememberDirective(user); + } + + req.session.save((err) => { + if (err) { + reject(err); + return; + } + + resolve(); + }); + }); + }); + }; + + /** + * Initiates an email change for the logged-in user. + * + * - Sends a confirmation token to the new email address. + * + * @throws {UserNotLoggedInError} When no user is currently logged in. + * @throws {InvalidEmailError} When the provided email is invalid. + * @throws {EmailTakenError} When the provided email is already in use. + * @throws {UserNotFoundError} When the logged-in user cannot be found. + * @throws {EmailNotVerifiedError} When the logged-in user's email is not verified. + */ + const changeEmail = async (newEmail: string, callback: TokenCallback) => { + if (!isLoggedIn()) { + throw new UserNotLoggedInError(); + } + + validateEmail(newEmail); + + const existing = await um.getByEmail(newEmail); + + if (existing) { + throw new EmailTakenError(); + } + + const user = await um.getById(um.getId()); + + if (!user) { + throw new UserNotFoundError(); + } + + if (!user.verified) { + throw new EmailNotVerifiedError(); + } + + await um.createConfirmationToken(user, newEmail, callback); + }; + + /** + * Confirms an email change using the provided token. + * + * @throws {ConfirmationNotFoundError} When the confirmation token cannot be found. + * @throws {ConfirmationExpiredError} When the confirmation token has expired. + * @throws {InvalidTokenError} When the provided token is invalid. + */ + const confirmChangeEmail = async (token: string) => { + const confirmation = await um.userConfirmationRepository().findOne({ + where: { token }, + }); + + if (!confirmation) { + throw new ConfirmationNotFoundError(); + } + + if (new Date(confirmation.expires) < new Date()) { + throw new ConfirmationExpiredError(); + } + + if (!hash.verify(token, confirmation.email)) { + throw new InvalidTokenError(); + } + + await um.userRepository().update(confirmation.user.id, { + verified: true, + email: confirmation.email, + }); + + if ( + isLoggedIn() && + req.session?.auth?.userId === ID.encode(confirmation.user.id) + ) { + req.session.auth.verified = true; + req.session.auth.email = confirmation.email; + } + + await um.userConfirmationRepository().remove(confirmation); + + return confirmation.email; + }; + + /** + * Confirms an email change using the provided token. + * + * @throws {ConfirmationNotFoundError} When the confirmation token cannot be found. + * @throws {ConfirmationExpiredError} When the confirmation token has expired. + * @throws {InvalidTokenError} When the provided token is invalid. + */ + const confirmEmail = async (token: string) => confirmChangeEmail(token); + + /** + * Confirms an email change using the provided token and logs in the user. + * + * - Logs in the user if not already logged in. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided email. + */ + const confirmEmailAndLogin = async (token: string) => + confirmChangeEmailAndLogin(token); + + /** + * Confirms an email change using the provided token and logs in the user. + * + * - Logs in the user if not already logged in. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided email. + */ + const confirmChangeEmailAndLogin = async ( + token: string, + remember = false, + ) => { + const email = await confirmChangeEmail(token); + + if (isLoggedIn()) { + return; + } + + const user = await um.getByEmail(email); + + if (!user) { + throw new UserNotFoundError(); + } + + await onLoginSuccessful(user, remember); + }; + + /** + * Updates the password for the specified user. + * + * @throws {InvalidPasswordError} When the provided password is invalid. + */ + const updatePasswordInternal = async (user: User, password: string) => { + await um + .userRepository() + .update(user.id, { password: hash.encode(password) }); + }; + + /** + * Initiates a password reset for the user identified by email. + * + * - Limits the number of open reset requests to `maxOpenRequests`. + * + * @throws {EmailNotVerifiedError} When the user's email is not verified. + * @throws {ResetDisabledError} When the user's reset functionality is disabled. + * @throws {TooManyResetsError} When the user has too many open reset requests. + */ + const resetPassword = async ( + email: string, + expiresAfter: string | number | null, + maxOpenRequests: number | null, + callback?: TokenCallback, + ) => { + validateEmail(email); + expiresAfter = !expiresAfter ? ms("6h") : ms(expiresAfter); + maxOpenRequests = + maxOpenRequests === null ? 2 : Math.max(1, maxOpenRequests); + + const user = await um.userRepository().findOne({ where: { email } }); + + if (!user || !user.verified) { + throw new EmailNotVerifiedError(); + } + + if (!user.resettable) { + throw new ResetDisabledError(); + } + + // find all open, non-expired reset requests + const openRequests = await um + .userResetRepository() + .find({ where: { user, expires: MoreThanOrEqual(new Date()) } }); + + if (openRequests.length >= maxOpenRequests) { + throw new TooManyResetsError(); + } + + const token = hash.encode(email); + const expires = new Date(Date.now() + ms(expiresAfter)); + await um.userResetRepository().insert({ user, token, expires }); + }; + + /** + * Confirms a password reset using the provided token and sets a new password. + * + * - Logs out the user from all sessions if `logout` is true. + * + * @throws {ResetNotFoundError} When the reset token cannot be found. + * @throws {ResetExpiredError} When the reset token has expired. + * @throws {ResetDisabledError} When the user's reset functionality is disabled. + * @throws {InvalidTokenError} When the provided token is invalid. + * @throws {InvalidPasswordError} When the provided password is invalid. + */ + const confirmResetPassword = async ( + token: string, + password: string, + logout = true, + ) => { + const reset = await um + .userResetRepository() + .findOne({ where: { token }, order: { expires: "DESC" } }); + + if (!reset) { + throw new ResetNotFoundError(); + } + + if (new Date(reset.expires) < new Date()) { + throw new ResetExpiredError(); + } + + if (!reset.user.resettable) { + throw new ResetDisabledError(); + } + + validatePassword(password); + + if (!hash.verify(token, reset.user.email)) { + throw new InvalidTokenError(); + } + + await updatePasswordInternal(reset.user, password); + + if (logout) { + await forceLogoutForUserById(reset.user.id); + } + + await um.userResetRepository().remove(reset); + }; + + /** + * Verifies the provided password against the logged-in user's password. + * + * @throws {UserNotLoggedInError} When no user is currently logged in. + * @throws {UserNotFoundError} When the logged-in user cannot be found. + */ + const verifyPassword = async (password: string) => { + if (!isLoggedIn()) { + throw new UserNotLoggedInError(); + } + + const user = await um.getUser(); + + if (!user) { + throw new UserNotFoundError(); + } + + return hash.verify(user.password, password); + }; + + return { + processRememberDirective, + + forceLogoutForUser, + forceLogoutForUserById, + + login, + logout, + logoutEverywhere, + logoutEverywhereElse, + + register: um.register, + + changeEmail, + confirmEmail, + confirmEmailAndLogin, + confirmChangeEmail, + confirmChangeEmailAndLogin, + + resetPassword, + confirmResetPassword, + + verifyPassword, + + isAdmin: um.isAdmin, + hasRole: um.hasRole, + + isLoggedIn, + isRemembered: um.isRemembered, + + getId: um.getId, + getEmail: um.getEmail, + getStatus: um.getStatus, + getVerified: um.getVerified, + getUser: um.getUser, + getRoleNames: um.getRoleNames, + getStatusName: um.getStatusName, + + userRepository: um.userRepository, + userConfirmationRepository: um.userConfirmationRepository, + userResetRepository: um.userResetRepository, + userRememberRepository: um.userRememberRepository, + + onLoginSuccessful, + + getById: um.getById, + getByEmail: um.getByEmail, + getByUsername: um.getByUsername, + }; +}; + +export const createAuthAdmin = ({ + req, + res, + datasource, + auth, +}: ReqResDatasource & { auth: Awaited> }) => { + const loginAsUser = async (user: User) => { + await auth.onLoginSuccessful(user, false); + }; + + const loginAsUserBy = async (identifier: { + id?: number; + email?: string; + username?: string; + }) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth + .userRepository() + .findOne({ where: { id: identifier.id } }); + } else if (identifier.email !== undefined) { + user = await auth + .userRepository() + .findOne({ where: { email: identifier.email } }); + } else if (identifier.username !== undefined) { + user = await auth + .userRepository() + .findOne({ where: { username: identifier.username } }); + } + + if (!user) { + throw new UserNotFoundError(); + } + + await loginAsUser(user); + }; + + const createUserInternal = async ( + requireUniqueUsername: boolean, + credentials: { email: string; password: string; username?: string }, + callback?: TokenCallback, + ) => { + validateEmail(credentials.email); + validatePassword(credentials.password); + + if (credentials.username) { + credentials.username = credentials.username.trim(); + } + + if (requireUniqueUsername) { + if (!credentials.username) { + throw new InvalidUsernameError(); + } + + const occurrences = await auth.userRepository().count({ + where: { username: credentials.username }, + }); + + if (occurrences > 0) { + throw new UsernameTakenError(); + } + } + + const hashed = hash.encode(credentials.password); + const verified = Boolean(callback); + + const user = await auth.userRepository().insert({ + email: credentials.email, + password: hashed, + username: credentials.username, + verified, + status: AuthStatus.Normal, + resettable: true, + rolemask: 0, + registered: new Date(), + lastLogin: null, + forceLogout: 0, + }); + }; + + /** + * Creates a new user with the provided credentials. + * + * @throws {InvalidEmailError} When the provided email is invalid. + * @throws {InvalidPasswordError} When the provided password is invalid. + * @throws {EmailTakenError} When the provided email is already in use. + */ + const createUser = async ( + credentials: { + email: string; + password: string; + username?: string; + }, + callback?: TokenCallback, + ) => { + return createUserInternal(false, credentials, callback); + }; + + /** + * Creates a new user with a unique username. + * + * - Ensures the username is unique before creating the user. + * + * @throws {InvalidEmailError} When the provided email is invalid. + * @throws {InvalidPasswordError} When the provided password is invalid. + * @throws {InvalidUsernameError} When the provided username is invalid. + * @throws {UsernameTakenError} When the provided username is already in use. + * @throws {EmailTakenError} When the provided email is already in use. + */ + const createUserWithUniqueUsername = async ( + credentials: { + email: string; + password: string; + username: string; + }, + callback?: TokenCallback, + ) => { + return createUserInternal(true, credentials, callback); + }; + + const addRoleForUser = async (user: User, role: number) => { + const rolemask = user.rolemask | role; + await auth.userRepository().update(user.id, { rolemask }); + }; + + /** + * Adds a role for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const addRoleForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + role: number, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return addRoleForUser(user, role); + }; + + const removeRoleForUser = async (user: User, role: number) => { + const rolemask = user.rolemask & ~role; + await auth.userRepository().update(user.id, { rolemask }); + }; + + /** + * Removes a role for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const removeRoleForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + role: number, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return removeRoleForUser(user, role); + }; + + /** + * Retrieves the roles for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const getRolesForUserBy = async (identifier: { + id?: number; + email?: string; + username?: string; + }) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return auth.getRoleNames(user.rolemask); + }; + + /** + * Checks if a user identified by id, email, or username has a specific role. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const hasRoleForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + role: number, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return (user.rolemask & role) === role; + }; + + const deleteUser = async (user: User) => { + await auth.userResetRepository().delete({ user }); + await auth.userRememberRepository().delete({ user }); + await auth.userConfirmationRepository().delete({ user }); + await auth.userRepository().delete(user); + }; + + /** + * Deletes a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const deleteUserBy = async (identifier: { + id?: number; + email?: string; + username?: string; + }) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return deleteUser(user); + }; + + const changePasswordForUser = async (user: User, password: string) => { + await auth + .userRepository() + .update(user.id, { password: hash.encode(password) }); + }; + + /** + * Changes the password for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const changePasswordForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + password: string, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return changePasswordForUser(user, password); + }; + + /** + * Sets the status for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const setStatusForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + status: number, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return auth.userRepository().update(user.id, { status }); + }; + + /** + * Initiates a password reset for the user, but only if that user + * has already verified their email address. + * + * - Ignores user.resettable (i.e., initiates reset regardless of this value). + * - Doesn't care about how many open requests there currently are for this user. + * + * @throws {EmailNotVerifiedError} When the user's email is not verified. + */ + const initiatePasswordResetForUser = async ( + user: User, + expiresAfter: string | number | null, + callback?: TokenCallback, + ) => { + if (!user.verified) { + throw new EmailNotVerifiedError(); + } + + expiresAfter = !expiresAfter ? ms("6h") : ms(expiresAfter); + const token = hash.encode(user.email); + const expires = new Date(Date.now() + ms(expiresAfter)); + await auth.userResetRepository().insert({ user, token, expires }); + + if (callback) { + callback(token); + } + }; + + /** + * Initiates a password reset for a user identified by id, email, or username. + * + * @throws {UserNotFoundError} When the user cannot be found by the provided identifier. + */ + const initiatePasswordResetForUserBy = async ( + identifier: { id?: number; email?: string; username?: string }, + expiresAfter: string | number | null, + callback?: TokenCallback, + ) => { + let user: User | null = null; + + if (identifier.id !== undefined) { + user = await auth.getById(identifier.id); + } else if (identifier.email !== undefined) { + user = await auth.getByEmail(identifier.email); + } else if (identifier.username !== undefined) { + user = await auth.getByUsername(identifier.username); + } + + if (!user) { + throw new UserNotFoundError(); + } + + return initiatePasswordResetForUser(user, expiresAfter, callback); + }; + + return { + loginAsUserBy, + createUser, + createUserWithUniqueUsername, + deleteUserBy, + getRolesForUserBy, + addRoleForUserBy, + removeRoleForUserBy, + hasRoleForUserBy, + changePasswordForUserBy, + setStatusForUserBy, + initiatePasswordResetForUserBy, + }; +}; + +export const middleware = ({ datasource }: { datasource: DataSource }) => { + return async (req: AuthenticatedRequest, res, next) => { + ensureRequiredMiddlewares(req.app); + req.auth = await createAuth({ req, res, datasource }); + req.authAdmin = createAuthAdmin({ req, res, datasource, auth: req.auth }); + await req.auth.processRememberDirective(); + next(); + }; +}; diff --git a/packages/express-session-auth/src/user-confirmation.entity.ts b/packages/express-session-auth/src/user-confirmation.entity.ts new file mode 100644 index 0000000..b28f392 --- /dev/null +++ b/packages/express-session-auth/src/user-confirmation.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { User } from "./user.entity.js"; + +@Entity("users_confirmations") +export class UserConfirmation { + @PrimaryGeneratedColumn("increment", { type: "int" }) + id: number; + + // Eagerly load this so we don't have to do this everywhere: + // relations: ["user"] + @OneToOne(() => User, { eager: true }) + @JoinColumn() + user: User; + + @Column({ type: "varchar", length: 200 }) + @Index() + token: string; + + @Column({ type: "varchar", length: 200 }) + @Index() + email: string; + + @Column({ type: "datetime" }) + expires: Date; +} diff --git a/packages/express-session-auth/src/user-remember.entity.ts b/packages/express-session-auth/src/user-remember.entity.ts new file mode 100644 index 0000000..4dd57a1 --- /dev/null +++ b/packages/express-session-auth/src/user-remember.entity.ts @@ -0,0 +1,26 @@ +import { + Column, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { User } from "./user.entity.js"; + +@Entity("users_remembers") +export class UserRemember { + @PrimaryGeneratedColumn("increment", { type: "int" }) + id: number; + + @OneToOne(() => User, { eager: true }) + @JoinColumn() + user: User; + + @Column({ type: "varchar", length: 200 }) + @Index() + token: string; + + @Column({ type: "datetime" }) + expires: Date; +} diff --git a/packages/express-session-auth/src/user-reset.entity.ts b/packages/express-session-auth/src/user-reset.entity.ts new file mode 100644 index 0000000..78b8643 --- /dev/null +++ b/packages/express-session-auth/src/user-reset.entity.ts @@ -0,0 +1,26 @@ +import { + Column, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { User } from "./user.entity.js"; + +@Entity("users_resets") +export class UserReset { + @PrimaryGeneratedColumn("increment", { type: "int" }) + id: number; + + @OneToOne(() => User, { eager: true }) + @JoinColumn() + user: User; + + @Column({ type: "varchar", length: 200 }) + @Index() + token: string; + + @Column({ type: "datetime" }) + expires: Date; +} diff --git a/packages/express-session-auth/src/user.entity.ts b/packages/express-session-auth/src/user.entity.ts new file mode 100644 index 0000000..2e7ef06 --- /dev/null +++ b/packages/express-session-auth/src/user.entity.ts @@ -0,0 +1,106 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; + +export const AuthStatus = { + Normal: 0, + Archived: 1, + Banned: 2, + Locked: 3, + PendingReview: 4, + Suspended: 5, +} as const; + +export const AuthRole = { + Admin: 1, + Author: 2, + Collaborator: 4, + Consultant: 8, + Consumer: 16, + Contributor: 32, + Coordinator: 64, + Creator: 128, + Developer: 256, + Director: 512, + Editor: 1024, + Employee: 2048, + Maintainer: 4096, + Manager: 8192, + Moderator: 16384, + Publisher: 32768, + Reviewer: 65536, + Subscriber: 131072, + SuperAdmin: 262144, + SuperEditor: 524288, + SuperModerator: 1048576, + Translator: 2097152, + // XX: 4194304, + // XX: 8388608, + // XX: 16777216, + // XX: 33554432, + // XX: 67108864, + // XX: 134217728, + // XX: 268435456, + // XX: 536870912, +} as const; + +const createMapFromEnum = (enumObj: Record) => { + return Object.fromEntries( + Object.entries(enumObj).map(([key, value]) => [value, key]), + ); +}; + +export const getStatusMap = () => createMapFromEnum(AuthStatus); +export const getRoleMap = () => createMapFromEnum(AuthRole); + +@Entity("users") +export class User { + @PrimaryGeneratedColumn("increment", { type: "int" }) + id: number; + + @Column({ type: "varchar", length: 50, nullable: true }) + @Index() + username: string; + + @Column({ type: "varchar", length: 100, unique: true }) + email: string; + + @Column({ type: "varchar", length: 1000 }) + password: string; + + @Column({ type: "int", default: AuthStatus.Normal }) + status: number; + + @Column({ type: "boolean", default: false }) + verified: boolean; + + @Column({ type: "boolean", default: true }) + resettable: boolean; + + @Column({ type: "int", default: 0 }) + rolemask: number; + + @Column({ type: "datetime" }) + registered: Date; + + @Column({ type: "datetime", nullable: true }) + lastLogin: Date; + + @Column({ type: "int", default: 0 }) + forceLogout: number; + + @CreateDateColumn({ type: "datetime" }) + createdAt: Date; + + @UpdateDateColumn({ type: "datetime" }) + updatedAt: Date; + + @DeleteDateColumn({ type: "datetime" }) + deletedAt: Date; +} diff --git a/packages/express-session-auth/src/util.ts b/packages/express-session-auth/src/util.ts new file mode 100644 index 0000000..613fbe5 --- /dev/null +++ b/packages/express-session-auth/src/util.ts @@ -0,0 +1,68 @@ +import ms from "@prsm/ms"; +import cookieParser from "cookie-parser"; +import { randomBytes } from "crypto"; +import express from "express"; +import session, { MemoryStore } from "express-session"; + +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const isValidEmail = (email: string) => emailRegex.test(email); + +// const isMiddlewareUsed = (app: express.Application, name: string) => +// !!app._router.stack.filter( +// (layer: { handle: { name: string } }) => +// layer && layer.handle && layer.handle.name === name, +// ).length; + +// export const ensureRequiredMiddlewares = (app: express.Application) => { +// const requiredMiddlewares = [ +// { +// name: "cookieParser", +// handler: () => cookieParser(), +// }, +// { +// name: "session", +// handler: () => +// session({ +// store: new MemoryStore({ captureRejections: true }), +// name: "pine", +// secret: randomBytes(32).toString("hex"), +// resave: false, +// saveUninitialized: true, +// cookie: { +// secure: process.env.NODE_ENV === "production", +// maxAge: ms("30m"), +// httpOnly: !(process.env.NODE_ENV === "production"), +// sameSite: "lax", +// }, +// }), +// }, +// ]; + +// for (const { name, handler } of requiredMiddlewares) { +// if (!isMiddlewareUsed(app, name)) { +// console.warn( +// `Required middleware '${name}' not found. It will automatically be used and you may not agree with the default configuration.`, +// ); +// app.use(handler()); +// } +// } +// }; + +const isMiddlewareUsed = (app: express.Application, name: string) => + !!app._router.stack.filter( + (layer: { handle: { name: string } }) => + layer && layer.handle && layer.handle.name === name, + ).length; + +export const ensureRequiredMiddlewares = (app: express.Application) => { + const requiredMiddlewares = ["cookieParser", "session"]; + + for (const name of requiredMiddlewares) { + if (!isMiddlewareUsed(app, name)) { + throw new Error( + `Required middleware '${name}' not found. Please ensure it is added to your express application.` + ); + } + } +}; diff --git a/packages/express-session-auth/tsconfig.json b/packages/express-session-auth/tsconfig.json new file mode 100644 index 0000000..a525b77 --- /dev/null +++ b/packages/express-session-auth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext" + }, + // "include": ["src", "express-session-auth.d.ts"] +} diff --git a/packages/express-session-auth/tsup.config.ts b/packages/express-session-auth/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/express-session-auth/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/hash/.npmignore b/packages/hash/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/hash/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/hash/README.md b/packages/hash/README.md new file mode 100644 index 0000000..8d878b9 --- /dev/null +++ b/packages/hash/README.md @@ -0,0 +1,43 @@ +# hash + +A very simple string hashing library on top of `node:crypto`. + +# Installation + +`npm install @prsm/hash` + +## create + +```typescript +import hash from "@prsm/hash"; + +hash.create("an unencrypted string"); +hash.create("an unencrypted string"); +hash.create("an unencrypted string"); + +// sha256:UfH7lmEc5dr65iFPmvsKthzAgMHtdV6Qb4FXYSqlnOaQoZmqQWLBrPnJGLZmQontirQZKO9nTIz+zs544n0x7Q==:6qG75Cp5hysNWs+8TO65fzc1FaSZxykaWa3iatPrw4s= +// sha256:Wq6vrcGG4mKlM7r8DAuDHcYxJlG8fOEoO2sNWofl/snmsZPTaBuy8Dg6i2J28TdcncSgK8EhrCqgv69h5Kk2xA==:QvAc6op8ScJex38AYrZUtFDd69c4OJv5SsVIRgR+FPw= +// sha256:e16qmZpJiy1qvGycPkJz0qQnCdyAguGAFV8rqCokiFml10nl9lVU1v0hZ6QBy+laI0AYkHsYtt6wMkEOuNhpMw==:L3bHZeriSAjy8wEIz/fURxhOqxa8KltuvpHPE/nE/eQ= +``` + +## verify + +```typescript +import hash from "@prsm/hash"; + +const valid = hash.verify( + "sha256:0SA+O819D52jZOqWuzIWa+KLyT+Ck+b5ze4HI7fAJOhRW3FYk527GnuVOS/pricLy1KqwUfk5wWyQx4z5x3fsA==:wPs8DRMOrZEJYeaPxZzccGPJSozGvNqRhhS6f8ITOyM=", + "an unencrypted string", +); +// valid = true +``` + +## custom hasher + +```typescript +import { Hasher } from "@prsm/hash"; + +const hash = new Hasher("sha512", 128); +// hash.create(".."); +// hash.verify("..", "sha512:...") +``` diff --git a/packages/hash/bump.config.ts b/packages/hash/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/hash/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/hash/bun.lockb b/packages/hash/bun.lockb new file mode 100755 index 0000000..94c73a6 Binary files /dev/null and b/packages/hash/bun.lockb differ diff --git a/packages/hash/package.json b/packages/hash/package.json new file mode 100644 index 0000000..7213290 --- /dev/null +++ b/packages/hash/package.json @@ -0,0 +1,27 @@ +{ + "name": "@prsm/hash", + "version": "1.0.2", + "author": "nvms", + "main": "./dist/index.js", + "devDependencies": { + "@types/node": "^22.4.1", + "bumpp": "^9.1.1", + "tsup": "^8.2.4", + "typescript": "^5.1.6" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "description": "", + "keywords": [], + "license": "Apache-2.0", + "scripts": { + "build": "tsup", + "release": "bumpp package.json && npm publish --access public" + }, + "type": "module" +} diff --git a/packages/hash/src/index.ts b/packages/hash/src/index.ts new file mode 100644 index 0000000..80a51a0 --- /dev/null +++ b/packages/hash/src/index.ts @@ -0,0 +1,58 @@ +import crypto from "node:crypto"; + +type Algorithm = "sha256" | "sha512"; + +class Hasher { + private algorithm: "sha256" | "sha512"; + private saltLength: number; + + constructor(algorithm: Algorithm = "sha256", saltLength: number = 64) { + this.algorithm = algorithm; + this.saltLength = saltLength; + } + + verify(encoded: string, unencoded: string): boolean { + const { algorithm, salt } = this.parse(encoded); + return this.hash(unencoded, algorithm, salt) === encoded; + } + + encode(string: string): string { + const salt = crypto.randomBytes(this.saltLength).toString("base64"); + return this.hash(string, this.algorithm, salt); + } + + hash(string: string, algorithm: Algorithm, salt: string): string { + const hash = crypto.createHash(algorithm); + hash.update(string); + hash.update(salt, "utf8"); + return `${algorithm}:${salt}:${hash.digest("base64")}`; + } + + private parse(encoded: string): { + algorithm: Algorithm; + salt: string; + digest: string; + } { + const parts = encoded.split(":"); + + if (parts.length !== 3) { + throw new Error( + `Invalid hash string. Expected 3 parts, got ${parts.length}`, + ); + } + + const algorithm: Algorithm = parts[0] as Algorithm; + const salt: string = parts[1]; + const digest: string = parts[2]; + + return { + algorithm, + salt, + digest, + }; + } +} + +export default new Hasher(); + +export { Hasher, type Algorithm }; diff --git a/packages/hash/tsconfig.json b/packages/hash/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/hash/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/hash/tsup.config.ts b/packages/hash/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/hash/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/ids/.npmignore b/packages/ids/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/ids/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/ids/README.md b/packages/ids/README.md new file mode 100644 index 0000000..ff49ea5 --- /dev/null +++ b/packages/ids/README.md @@ -0,0 +1,30 @@ +# ids + +[![NPM version](https://img.shields.io/npm/v/@prsm/ids?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/ids) + +Short, obfuscated, collision-proof, and reversible identifiers. + +Because sometimes internal identifiers are sensitive, or you just don't want to let a user know that their ID is 1. + +```typescript +import ID from "@prsm/ids"; + +ID.encode(12389125); // phsV8T +ID.decode("phsV8T"); // 12389125 +``` + +You can (and should) set your own alphabet string: + +```typescript +ID.alphabet = "GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT"; +ID.alphabet = "TgzMhJXtRSVBnHFksZQc5j-yGx84W3rNDfK6p_Cbqd29YLm7Pwv"; +ID.alphabet = "kbHn53dZphT2FvGMBxYJKqS7-cPV_Ct6LwjWRDfXmygzrQ48N9s"; +``` + +If your use case makes sense, you can also generate a random alphabet string with `randomizeAlphabet`. + +When the alphabet changes, though, the encoded IDs will change as well. Decoding will still work, but the decoded value will be different. + +```typescript +ID.randomizeAlphabet(); +``` diff --git a/packages/ids/bump.config.ts b/packages/ids/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/ids/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/ids/bun.lockb b/packages/ids/bun.lockb new file mode 100755 index 0000000..342b14c Binary files /dev/null and b/packages/ids/bun.lockb differ diff --git a/packages/ids/package.json b/packages/ids/package.json new file mode 100644 index 0000000..bede136 --- /dev/null +++ b/packages/ids/package.json @@ -0,0 +1,30 @@ +{ + "name": "@prsm/ids", + "version": "1.1.1", + "description": "", + "main": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "bun tests/index.ts", + "release": "bumpp package.json && npm publish --access public" + }, + "author": "nvms", + "license": "Apache-2.0", + "dependencies": { + "long": "^5.2.3" + }, + "devDependencies": { + "bumpp": "^9.5.1", + "manten": "^0.6.0", + "tsup": "^8.2.4", + "typescript": "^4.9.5" + } +} diff --git a/packages/ids/src/index.ts b/packages/ids/src/index.ts new file mode 100644 index 0000000..9dbe743 --- /dev/null +++ b/packages/ids/src/index.ts @@ -0,0 +1,74 @@ +import long from "long"; + +export default class ID { + private static MAX_INT32 = 2_147_483_647; + private static MULTIPLIER = 4_294_967_296; + + static alphabet: string = + "23456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ-_"; + static prime: number = 1_125_812_041; + static inverse: number = 348_986_105; + static random: number = 998_048_641; + + static get base(): number { + return ID.alphabet.length; + } + + private static shorten(id: number): string { + let result = ""; + + while (id > 0) { + result = ID.alphabet[id % ID.base] + result; + id = Math.floor(id / ID.base); + } + + return result; + } + + private static unshorten(str: string): number { + let result = 0; + + for (let i = 0; i < str.length; i++) { + result = result * ID.base + ID.alphabet.indexOf(str[i]); + } + + return result; + } + + static encode = (num: number): string => { + if (num > ID.MAX_INT32) { + throw new Error( + `Number (${num}) is too large to encode. MAX_INT32 is ${ID.MAX_INT32}`, + ); + } + + const n: long = long.fromInt(num); + + return ID.shorten( + n + .multiply(ID.prime) + .and(long.fromInt(ID.MAX_INT32)) + .xor(ID.random) + .toInt(), + ); + }; + + static decode = (str: string): number => { + const n: long = long.fromInt(ID.unshorten(str)); + + return n + .xor(ID.random) + .multiply(ID.inverse) + .and(long.fromInt(ID.MAX_INT32)) + .toInt(); + }; + + static randomizeAlphabet(): void { + const array = ID.alphabet.split(''); + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + ID.alphabet = array.join(''); + } +} diff --git a/packages/ids/tests/index.ts b/packages/ids/tests/index.ts new file mode 100644 index 0000000..85e1fda --- /dev/null +++ b/packages/ids/tests/index.ts @@ -0,0 +1,36 @@ +import { describe, expect } from "manten"; +import ID from "../src"; + +describe("ids", async ({ test }) => { + test("encodes as expected", () => { + const encoded = ID.encode(12389125); + expect(encoded).toBe("7rYTs_"); + }); + + test("decodes as expected", () => { + const decoded = ID.decode("7rYTs_"); + expect(decoded).toBe(12389125); + }); + + test("changing the alphabet is effective", () => { + ID.alphabet = "GZwBHpfWybgQ5d_2mM-jh84K69tqYknx7LN3zvDrcSJVRPXsCFT"; + expect(ID.encode(12389125)).toBe("phsV8T"); + expect(ID.decode("phsV8T")).toBe(12389125); + }); + + test("shuffling the alphabet still allows you to decode things", () => { + ID.randomizeAlphabet(); + const encoded = ID.encode(12389125); + const decoded = ID.decode(encoded); + expect(decoded).toBe(12389125); + + console.log(ID.alphabet); + + ID.randomizeAlphabet(); + // const encoded2 = ID.encode(12389125); + const decoded2 = ID.decode(encoded); + expect(decoded2).toBe(12389125); + + // expect(encoded).not.toBe(encoded2); + }) +}); diff --git a/packages/ids/tsconfig.json b/packages/ids/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/ids/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/ids/tsup.config.ts b/packages/ids/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/ids/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/jwt/.npmignore b/packages/jwt/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/jwt/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/jwt/README.md b/packages/jwt/README.md new file mode 100644 index 0000000..b37beb3 --- /dev/null +++ b/packages/jwt/README.md @@ -0,0 +1,43 @@ +# jwt + +A package for encoding, decoding, and verifying JWTs. + +# Installation + +`npm install @prsm/jwt` + +## Encoding + +```typescript +import { encode } from "@prsm/jwt"; + +const payload = { + iat: Date.now(), + exp: Date.now() + 3600, +}; + +const token = encode(payload, process.env.SIGNING_KEY); +``` + +## Verifying + +```typescript +import { verify } from "@prsm/jwt"; + +const result = verify(token, process.env.SIGNING_KEY); + +if (!result.sig) throw new Error("signature verification failed"); +if (result.exp) throw new Error("token has expired"); +if (!result.nbf) throw new Error("token is not yet valid") + +// token payload is available at result.decoded.payload +``` + +## Decoding + +```typescript +import { decode } from "@prsm/jwt"; + +const result = decode(token); +// { header: { alg: "HS256", typ: "JWT" }, payload: { iat: 123456789, exp: 123456789 }, signature: "..." +``` diff --git a/packages/jwt/bump.config.ts b/packages/jwt/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/jwt/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/jwt/bun.lockb b/packages/jwt/bun.lockb new file mode 100755 index 0000000..713968e Binary files /dev/null and b/packages/jwt/bun.lockb differ diff --git a/packages/jwt/package.json b/packages/jwt/package.json new file mode 100644 index 0000000..b1af98a --- /dev/null +++ b/packages/jwt/package.json @@ -0,0 +1,31 @@ +{ + "name": "@prsm/jwt", + "version": "1.0.8", + "description": "", + "main": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "bun tests/index.ts", + "release": "bumpp package.json && npm publish --access public" + }, + "keywords": [], + "author": "nvms", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.4.1", + "bumpp": "^9.5.1", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "dependencies": { + "ecdsa-sig-formatter": "^1.0.11" + } +} diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts new file mode 100644 index 0000000..39a79b8 --- /dev/null +++ b/packages/jwt/src/index.ts @@ -0,0 +1,314 @@ +import { derToJose, joseToDer } from "ecdsa-sig-formatter"; +import crypto from "node:crypto"; + +export interface JWTPayload { + /** expiration */ + exp?: number; + /** subject */ + sub?: string | number; + /** issued at */ + iat?: number; + /** not before */ + nbf?: number; + /** jwt id */ + jti?: number; + /** issuer */ + iss?: string; + /** audience */ + aud?: string | number; + /** whatever */ + [k: string]: any; +} + +export interface JWTHeader { + /** encoding alg used */ + alg: string; + /** token type */ + type: "JWT"; + /** key id */ + kid?: string; +} + +export interface JWTParts { + header: JWTHeader; + payload: JWTPayload; + signature: Buffer; +} + +export interface VerifyOptions { + alg?: string; + exp?: boolean; + sub?: string | number; + iat?: number; + nbf?: boolean; + jti?: number; + iss?: string; + aud?: string | number; +} + +export interface VerifyResult { + /** true: signature is valid */ + sig?: boolean; + /** true: payload.iat matches opts.iat */ + iat?: boolean; + /** true: the current time is later or equal to payload.nbf, false: this jwt should NOT be accepted */ + nbf?: boolean; + /** true: token is expired (payload.exp < now) */ + exp?: boolean; + /** true: payload.jti matches opts.jti */ + jti?: boolean; + /** true: payload.iss matches opts.iss */ + iss?: boolean; + /** true: payload.sub matches opts.sub */ + sub?: boolean; + /** true: payload.aud matches opts.aud */ + aud?: boolean; + + decoded: JWTParts; +} + +const algorithms = [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", +] as const; +type Algorithm = (typeof algorithms)[number]; + +function isValidAlgorithm(alg: Algorithm): boolean { + return algorithms.includes(alg); +} + +interface IAlgorithm { + sign(encoded: string, secret: string | Buffer): string; + verify(encoded: string, signature: string, secret: string | Buffer): boolean; +} + +const Algorithms: { [k: string]: IAlgorithm } = { + HS256: createHmac(256), + HS384: createHmac(384), + HS512: createHmac(512), + RS256: createSign(256), + RS384: createSign(384), + RS512: createSign(512), + ES256: createEcdsa(256), +} as const; + +function createHmac(bits: number): IAlgorithm { + function sign(encoded: string, secret: string | Buffer): string { + return crypto + .createHmac(`sha${bits}`, secret) + .update(encoded) + .digest("base64"); + } + + function verify( + encoded: string, + signature: string, + secret: string | Buffer, + ): boolean { + return sign(encoded, secret) === signature; + } + + return { sign, verify }; +} + +function createSign(bits: number): IAlgorithm { + const algorithm = `RSA-SHA${bits}`; + + function sign(encoded: string, secret: string | Buffer): string { + return crypto + .createSign(algorithm) + .update(encoded) + .sign(secret.toString(), "base64"); + } + + function verify( + encoded: string, + signature: string, + secret: string | Buffer, + ): boolean { + const v = crypto.createVerify(algorithm); + v.update(encoded); + return v.verify(secret, signature, "base64"); + } + + return { sign, verify }; +} + +function createEcdsa(bits: number): IAlgorithm { + const algorithm = `RSA-SHA${bits}`; + + function sign(encoded: string, secret: string | Buffer): string { + const sig = crypto + .createSign(algorithm) + .update(encoded) + .sign({ key: secret.toString() }, "base64"); + + return derToJose(sig, `ES${bits}`); + } + + function verify( + encoded: string, + signature: string, + secret: string | Buffer, + ): boolean { + signature = joseToDer(signature, `ES${bits}`).toString("base64"); + const v = crypto.createVerify(algorithm); + v.update(encoded); + return v.verify(secret, signature, "base64"); + } + + return { sign, verify }; +} + +function encodeJSONBase64(obj: any): string { + const j = JSON.stringify(obj); + return Base64ToURLEncoded(Buffer.from(j).toString("base64")); +} + +function decodeJSONBase64(str: string) { + const dec = Buffer.from(URLEncodedToBase64(str), "base64").toString("utf-8"); + try { + return JSON.parse(dec); + } catch (e) { + throw e; + } +} + +function Base64ToURLEncoded(b64: string): string { + return b64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function URLEncodedToBase64(enc: string): string { + enc = enc.toString(); + const pad = 4 - (enc.length % 4); + + if (pad !== 4) { + for (let i = 0; i < pad; i++) { + enc += "="; + } + } + + return enc.replace(/\-/g, "+").replace(/_/g, "/"); +} + +/** + * Encodes a payload into a JWT string with a specified algorithm. + * + * @param {JWTPayload} payload - The payload to encode into the JWT. + * @param {string | Buffer} key - The secret key used to sign the JWT. + * @param {Algorithm} alg - The algorithm used to sign the JWT. Defaults to "HS256". + * @throws {Error} If an invalid algorithm type is provided. + * @returns {string} The encoded JWT string. + */ +function encode( + payload: JWTPayload, + key: string | Buffer, + alg: Algorithm = "HS256", +): string { + if (!isValidAlgorithm(alg)) { + throw new Error( + `${alg} is an invalid algorithm type. Must be one of ${algorithms}`, + ); + } + + const b64header = encodeJSONBase64({ alg, type: "JWT" }); + const b64payload = encodeJSONBase64(payload); + const unsigned = `${b64header}.${b64payload}`; + const signer = Algorithms[alg]; + const sig = Base64ToURLEncoded(signer.sign(unsigned, key)); + + return `${unsigned}.${sig}`; +} + +/** + * Decodes a JWT-encoded string and returns an object containing the decoded header, payload, and signature. + * + * @param {string} encoded - The JWT-encoded string to decode. + * @throws {Error} If the encoded string does not have exactly three parts separated by periods. + * @returns {JWTParts} An object containing the decoded header, payload, and signature of the token. + */ +function decode(encoded: string): JWTParts { + const parts = encoded.split("."); + if (parts.length !== 3) { + throw new Error( + `Decode expected 3 parts to encoded token, got ${parts.length}`, + ); + } + + const header: JWTHeader = decodeJSONBase64(parts[0]); + const payload: JWTPayload = decodeJSONBase64(parts[1]); + const signature = Buffer.from(URLEncodedToBase64(parts[2]), "base64"); + + return { header, payload, signature }; +} + +/** + * Verifies an encoded token with the given secret key and options. + * @param encoded + * @param key Secret key used to verify the signature of the encoded token. + * @param opts The opts parameter of the verify function is an optional object that can contain the following properties: + * - alg: A string specifying the algorithm used to sign the token. If this property is not present in opts, the alg property from the decoded token header will be used. + * - iat: A number representing the timestamp when the token was issued. If present, this property will be compared to the iat claim in the token's payload. + * - iss: A string representing the issuer of the token. If present, this property will be compared to the iss claim in the token's payload. + * - jti: A string representing the ID of the token. If present, this property will be compared to the jti claim in the token's payload. + * - sub: A string representing the subject of the token. If present, this property will be compared to the sub claim in the token's payload. + * - aud: A string or number representing the intended audience(s) for the token. If present, this property will be compared to the aud claim in the token's payload. + * @returns + */ +function verify( + encoded: string, + key: string | Buffer, + opts: VerifyOptions = {}, +): VerifyResult { + const decoded = decode(encoded); + const { payload } = decoded; + const parts = encoded.split("."); + const alg = opts.alg ?? decoded.header.alg ?? "HS256"; + const now = Date.now(); + const verifier = Algorithms[alg]; + const result: VerifyResult = { decoded }; + + result.sig = verifier.verify( + `${parts[0]}.${parts[1]}`, + URLEncodedToBase64(parts[2]), + key, + ); + + if (payload.exp !== undefined) { + result.exp = payload.exp < now; + } + + if (payload.nbf !== undefined) { + result.nbf = now >= payload.nbf; + } + + if (opts.iat !== undefined) { + result.iat = payload.iat === opts.iat; + } + + if (opts.iss !== undefined) { + result.iss = payload.iss === opts.iss; + } + + if (opts.jti !== undefined) { + result.jti = payload.jti !== opts.jti; + } + + if (opts.sub !== undefined) { + result.sub = payload.sub === opts.sub; + } + + if (opts.aud !== undefined) { + result.aud = payload.aud === opts.aud; + } + + return result; +} + +const jwt = { encode, decode, verify }; +export { decode, encode, verify }; +export default jwt; diff --git a/packages/jwt/tsconfig.json b/packages/jwt/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/jwt/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/jwt/tsup.config.ts b/packages/jwt/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/jwt/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/keepalive-ws/.npmignore b/packages/keepalive-ws/.npmignore new file mode 100644 index 0000000..bf40d27 --- /dev/null +++ b/packages/keepalive-ws/.npmignore @@ -0,0 +1,2 @@ +node_modules +src \ No newline at end of file diff --git a/packages/keepalive-ws/README.md b/packages/keepalive-ws/README.md new file mode 100644 index 0000000..f00f130 --- /dev/null +++ b/packages/keepalive-ws/README.md @@ -0,0 +1,98 @@ +For a TCP-based, node-only solution with a similar API, see [duplex](https://github.com/node-prism/duplex). + +# keepalive-ws + +A command server and client for simplified WebSocket communication, with builtin ping and latency messaging. + +Built for [grove](https://github.com/node-prism/grove), but works anywhere. + +### Server + +For node. + +```typescript +import { KeepAliveServer, WSContext } from "@prsm/keepalive-ws/server"; + +const ws = new KeepAliveServer({ + // Where to mount this server and listen to messages. + path: "/", + // How often to send ping messages to connected clients. + pingInterval: 30_000, + // Calculate round-trip time and send latency updates + // to clients every 5s. + latencyInterval: 5_000, +}); + +ws.registerCommand( + "authenticate", + async (c: WSContext) => { + // use c.payload to authenticate c.connection + return { ok: true, token: "..." }; + }, +); + +ws.registerCommand( + "throws", + async (c: WSContext) => { + throw new Error("oops"); + }, +); +``` + +Extended API: + +- Rooms + + It can be useful to collect connections into rooms. + + - `addToRoom(roomName: string, connection: Connection): void` + - `removeFromRoom(roomName: string, connection: Connection): void` + - `getRoom(roomName: string): Connection[]` + - `clearRoom(roomName: string): void` +- Command middleware +- Broadcasting to: + - all + - `broadcast(command: string, payload: any, connections?: Connection[]): void` + - all connections that share the same IP + - `broadcastRemoteAddress(c: Connection, command: string, payload: any): void` + - rooms + - `broadcastRoom(roomName: string, command: string, payload: any): void` + +### Client + +For the browser. + +```typescript +import { KeepAliveClient } from "@prsm/keepalive-ws/client"; + +const opts = { + // After 30s (+ maxLatency) of no ping, assume we've disconnected and attempt a + // reconnection if shouldReconnect is true. + // This number should be coordinated with the pingInterval from KeepAliveServer. + pingTimeout: 30_000, + // Try to reconnect whenever we are disconnected. + shouldReconnect: true, + // This number, added to pingTimeout, is the maximum amount of time + // that can pass before the connection is considered closed. + // In this case, 32s. + maxLatency: 2_000, + // How often to try and connect during reconnection phase. + reconnectInterval: 2_000, + // How many times to try and reconnect before giving up. + maxReconnectAttempts: Infinity, +}; + +const ws = new KeepAliveClient("ws://localhost:8080", opts); + +const { ok, token } = await ws.command("authenticate", { + username: "user", + password: "pass", +}); + +const result = await ws.command("throws", {}); +// result is: { error: "oops" } + +ws.on("latency", (e: CustomEvent<{ latency: number }>) => { + // e.detail.latency is round-trip time in ms +}); +``` diff --git a/packages/keepalive-ws/bump.config.ts b/packages/keepalive-ws/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/keepalive-ws/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/keepalive-ws/bun.lockb b/packages/keepalive-ws/bun.lockb new file mode 100755 index 0000000..56b08aa Binary files /dev/null and b/packages/keepalive-ws/bun.lockb differ diff --git a/packages/keepalive-ws/package.json b/packages/keepalive-ws/package.json new file mode 100644 index 0000000..194855d --- /dev/null +++ b/packages/keepalive-ws/package.json @@ -0,0 +1,49 @@ +{ + "name": "@prsm/keepalive-ws", + "version": "0.3.1", + "description": "", + "type": "module", + "main": "./dist/server/index.js", + "exports": { + ".": "./dist/index.js", + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "require": "./dist/server/index.cjs" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "import": "./dist/client/index.js", + "require": "./dist/client/index.cjs" + } + }, + "typesVersions": { + "*": { + "server": [ + "dist/server/index.d.ts" + ], + "client": [ + "dist/client/index.d.ts" + ] + } + }, + "scripts": { + "dev": "tsc --watch", + "build:prep": "rm -rf dist && mkdir dist && mkdir dist/server && mkdir dist/client", + "build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server", + "build:client": "tsup src/client/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client", + "build": "npm run build:prep && npm run build:server && npm run build:client", + "release": "bumpp package.json && npm publish --access public" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.9.0" + }, + "devDependencies": { + "@types/ws": "^8.5.3", + "bumpp": "^9.1.1", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + } +} diff --git a/packages/keepalive-ws/src/client/client.ts b/packages/keepalive-ws/src/client/client.ts new file mode 100644 index 0000000..03177e9 --- /dev/null +++ b/packages/keepalive-ws/src/client/client.ts @@ -0,0 +1,157 @@ +import { Connection } from "./connection"; + +type KeepAliveClientOptions = Partial<{ + /** + * The number of milliseconds to wait before considering the connection closed due to inactivity. + * When this happens, the connection will be closed and a reconnect will be attempted if @see KeepAliveClientOptions.shouldReconnect is true. + * This number should match the server's `pingTimeout` option. + * @default 30000 + * @see maxLatency. + */ + pingTimeout: number; + + /** + * This number plus @see pingTimeout is the maximum amount of time that can pass before the connection is considered closed. + * @default 2000 + */ + maxLatency: number; + + /** + * Whether or not to reconnect automatically. + * @default true + */ + shouldReconnect: boolean; + + /** + * The number of milliseconds to wait between reconnect attempts. + * @default 2000 + */ + reconnectInterval: number; + + /** + * The number of times to attempt to reconnect before giving up and + * emitting a `reconnectfailed` event. + * @default Infinity + */ + maxReconnectAttempts: number; +}>; + +const defaultOptions = (opts: KeepAliveClientOptions = {}) => { + opts.pingTimeout = opts.pingTimeout ?? 30_000; + opts.maxLatency = opts.maxLatency ?? 2_000; + opts.shouldReconnect = opts.shouldReconnect ?? true; + opts.reconnectInterval = opts.reconnectInterval ?? 2_000; + opts.maxReconnectAttempts = opts.maxReconnectAttempts ?? Infinity; + return opts; +}; + +export class KeepAliveClient extends EventTarget { + connection: Connection; + url: string; + socket: WebSocket; + pingTimeout: ReturnType; + options: KeepAliveClientOptions; + isReconnecting = false; + + constructor(url: string, opts: KeepAliveClientOptions = {}) { + super(); + this.url = url; + this.socket = new WebSocket(url); + this.connection = new Connection(this.socket); + this.options = defaultOptions(opts); + this.applyListeners(); + } + + get on() { + return this.connection.addEventListener.bind(this.connection); + } + + applyListeners() { + this.connection.addEventListener("connection", () => { + this.heartbeat(); + }); + + this.connection.addEventListener("close", () => { + this.reconnect(); + }); + + this.connection.addEventListener("ping", () => { + this.heartbeat(); + }); + + this.connection.addEventListener( + "message", + (ev: CustomEventInit) => { + this.dispatchEvent(new CustomEvent("message", ev)); + }, + ); + } + + heartbeat() { + clearTimeout(this.pingTimeout); + + this.pingTimeout = setTimeout(() => { + if (this.options.shouldReconnect) { + this.reconnect(); + } + }, this.options.pingTimeout + this.options.maxLatency); + } + + async reconnect() { + if (this.isReconnecting) { + return; + } + + this.isReconnecting = true; + + let attempt = 1; + + if (this.socket) { + try { + this.socket.close(); + } catch (e) {} + } + + const connect = () => { + this.socket = new WebSocket(this.url); + this.socket.onerror = () => { + attempt++; + + if (attempt <= this.options.maxReconnectAttempts) { + setTimeout(connect, this.options.reconnectInterval); + } else { + this.isReconnecting = false; + + this.connection.dispatchEvent(new Event("reconnectfailed")); + this.connection.dispatchEvent(new Event("reconnectionfailed")); + } + }; + + this.socket.onopen = () => { + this.isReconnecting = false; + this.connection.socket = this.socket; + + this.connection.applyListeners(true); + + this.connection.dispatchEvent(new Event("connection")); + this.connection.dispatchEvent(new Event("connected")); + this.connection.dispatchEvent(new Event("connect")); + + this.connection.dispatchEvent(new Event("reconnection")); + this.connection.dispatchEvent(new Event("reconnected")); + this.connection.dispatchEvent(new Event("reconnect")); + }; + }; + + connect(); + } + + async command( + command: string, + payload?: any, + expiresIn?: number, + callback?: Function, + ) { + return this.connection.command(command, payload, expiresIn, callback); + } +} diff --git a/packages/keepalive-ws/src/client/connection.ts b/packages/keepalive-ws/src/client/connection.ts new file mode 100644 index 0000000..b04cce8 --- /dev/null +++ b/packages/keepalive-ws/src/client/connection.ts @@ -0,0 +1,198 @@ +import { IdManager } from "./ids"; +import { Queue, QueueItem } from "./queue"; + +type Command = { + id?: number; + command: string; + payload?: any; +}; + +type LatencyPayload = { + /** Round trip time in milliseconds. */ + latency: number; +}; + +export declare interface Connection extends EventTarget { + addEventListener(type: "message", listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a connection is made. */ + addEventListener(type: "connection", listener: () => any, options?: boolean | AddEventListenerOptions): void; + /** Emits when a connection is made. */ + addEventListener(type: "connected", listener: () => any, options?: boolean | AddEventListenerOptions): void; + /** Emits when a connection is made. */ + addEventListener(type: "connect", listener: () => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a connection is closed. */ + addEventListener(type: "close", listener: () => any, options?: boolean | AddEventListenerOptions): void; + /** Emits when a connection is closed. */ + addEventListener(type: "closed", listener: () => any, options?: boolean | AddEventListenerOptions): void; + /** Emits when a connection is closed. */ + addEventListener(type: "disconnect", listener: () => any, options?: boolean | AddEventListenerOptions): void; + /** Emits when a connection is closed. */ + addEventListener(type: "disconnected", listener: () => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a reconnect event is successful. */ + addEventListener(type: "reconnect", listener: () => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a reconnect fails after @see KeepAliveClientOptions.maxReconnectAttempts attempts. */ + addEventListener(type: "reconnectfailed", listener: () => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a ping message is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */ + addEventListener(type: "ping", listener: (ev: CustomEventInit<{}>) => any, options?: boolean | AddEventListenerOptions): void; + + /** Emits when a latency event is received from @see KeepAliveServer from `@prsm/keepalive-ws/server`. */ + addEventListener(type: "latency", listener: (ev: CustomEventInit) => any, options?: boolean | AddEventListenerOptions): void; + + addEventListener(type: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions): void; +} + +export class Connection extends EventTarget { + socket: WebSocket; + ids = new IdManager(); + queue = new Queue(); + callbacks: { [id: number]: (error: Error | null, result?: any) => void } = {}; + + constructor(socket: WebSocket) { + super(); + this.socket = socket; + this.applyListeners(); + } + + /** + * Adds an event listener to the target. + * @param event The name of the event to listen for. + * @param listener The function to call when the event is fired. + * @param options An options object that specifies characteristics about the event listener. + */ + on(event: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions) { + this.addEventListener(event, listener, options); + } + + /** + * Removes the event listener previously registered with addEventListener. + * @param event A string that specifies the name of the event for which to remove an event listener. + * @param listener The event listener to be removed. + * @param options An options object that specifies characteristics about the event listener. + */ + off(event: string, listener: (ev: CustomEvent) => any, options?: boolean | AddEventListenerOptions) { + this.removeEventListener(event, listener, options); + } + + sendToken(cmd: Command, expiresIn: number) { + try { + this.socket.send(JSON.stringify(cmd)); + } catch (e) { + this.queue.add(cmd, expiresIn); + } + } + + applyListeners(reconnection = false) { + const drainQueue = () => { + while (!this.queue.isEmpty) { + const item = this.queue.pop() as QueueItem; + this.sendToken(item.value, item.expiresIn); + } + }; + + if (reconnection) drainQueue(); + + // @ts-ignore + this.socket.onopen = (socket: WebSocket, ev: Event): any => { + drainQueue(); + this.dispatchEvent(new Event("connection")); + this.dispatchEvent(new Event("connected")); + this.dispatchEvent(new Event("connect")); + }; + + this.socket.onclose = (event: CloseEvent) => { + this.dispatchEvent(new Event("close")); + this.dispatchEvent(new Event("closed")); + this.dispatchEvent(new Event("disconnected")); + this.dispatchEvent(new Event("disconnect")); + }; + + this.socket.onmessage = async (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + + this.dispatchEvent(new CustomEvent("message", { detail: data })); + + if (data.command === "latency:request") { + this.dispatchEvent( + new CustomEvent( + "latency:request", + { detail: { latency: data.payload.latency ?? undefined }} + ) + ); + this.command("latency:response", { latency: data.payload.latency ?? undefined }, null); + } else if (data.command === "latency") { + this.dispatchEvent( + new CustomEvent( + "latency", + { detail: { latency: data.payload ?? undefined }} + ) + ); + } else if (data.command === "ping") { + this.dispatchEvent(new CustomEvent("ping", {})); + this.command("pong", {}, null); + } else { + this.dispatchEvent(new CustomEvent(data.command, { detail: data.payload })); + } + + if (this.callbacks[data.id]) { + this.callbacks[data.id](null, data.payload); + } + } catch (e) { + this.dispatchEvent(new Event("error")); + } + }; + } + + async command(command: string, payload: any, expiresIn: number = 30_000, callback: Function | null = null) { + const id = this.ids.reserve(); + const cmd = { id, command, payload: payload ?? {} }; + + this.sendToken(cmd, expiresIn); + + if (expiresIn === null) { + this.ids.release(id); + delete this.callbacks[id]; + return null; + } + + const response = this.createResponsePromise(id); + const timeout = this.createTimeoutPromise(id, expiresIn); + + if (typeof callback === "function") { + const ret = await Promise.race([response, timeout]); + callback(ret); + return ret; + } else { + return Promise.race([response, timeout]); + } + } + + createTimeoutPromise(id: number, expiresIn: number) { + return new Promise((_, reject) => { + setTimeout(() => { + this.ids.release(id); + delete this.callbacks[id]; + reject(new Error(`Command ${id} timed out after ${expiresIn}ms.`)); + }, expiresIn); + }); + } + + createResponsePromise(id: number) { + return new Promise((resolve, reject) => { + this.callbacks[id] = (error: Error | null, result?: any) => { + this.ids.release(id); + delete this.callbacks[id]; + if (error) { + reject(error); + } else { + resolve(result); + } + }; + }); + } +} diff --git a/packages/keepalive-ws/src/client/ids.ts b/packages/keepalive-ws/src/client/ids.ts new file mode 100644 index 0000000..1d0d252 --- /dev/null +++ b/packages/keepalive-ws/src/client/ids.ts @@ -0,0 +1,44 @@ +export class IdManager { + ids: Array = []; + index: number = 0; + maxIndex: number; + + constructor(maxIndex: number = 2 ** 16 - 1) { + this.maxIndex = maxIndex; + } + + release(id: number) { + if (id < 0 || id > this.maxIndex) { + throw new TypeError( + `ID must be between 0 and ${this.maxIndex}. Got ${id}.`, + ); + } + this.ids[id] = false; + } + + reserve(): number { + const startIndex = this.index; + + while (true) { + const i = this.index; + + if (!this.ids[i]) { + this.ids[i] = true; + + return i; + } + + if (this.index >= this.maxIndex) { + this.index = 0; + } else { + this.index++; + } + + if (this.index === startIndex) { + throw new Error( + `All IDs are reserved. Make sure to release IDs when they are no longer used.`, + ); + } + } + } +} diff --git a/packages/keepalive-ws/src/client/index.ts b/packages/keepalive-ws/src/client/index.ts new file mode 100644 index 0000000..e5a5473 --- /dev/null +++ b/packages/keepalive-ws/src/client/index.ts @@ -0,0 +1,2 @@ +export { KeepAliveClient } from "./client"; +export { Connection } from "./connection"; diff --git a/packages/keepalive-ws/src/client/queue.ts b/packages/keepalive-ws/src/client/queue.ts new file mode 100644 index 0000000..948754e --- /dev/null +++ b/packages/keepalive-ws/src/client/queue.ts @@ -0,0 +1,50 @@ +export class QueueItem { + value: any; + expireTime: number; + + constructor(value: any, expiresIn: number) { + this.value = value; + this.expireTime = Date.now() + expiresIn; + } + + get expiresIn() { + return this.expireTime - Date.now(); + } + + get isExpired() { + return Date.now() > this.expireTime; + } +} + +export class Queue { + items: any[] = []; + + add(item: any, expiresIn: number) { + this.items.push(new QueueItem(item, expiresIn)); + } + + get isEmpty() { + let i = this.items.length; + + while (i--) { + if (this.items[i].isExpired) { + this.items.splice(i, 1); + } else { + return false; + } + } + + return true; + } + + pop(): QueueItem | null { + while (this.items.length) { + const item = this.items.shift() as QueueItem; + if (!item.isExpired) { + return item; + } + } + + return null; + } +} diff --git a/packages/keepalive-ws/src/index.ts b/packages/keepalive-ws/src/index.ts new file mode 100644 index 0000000..80a56be --- /dev/null +++ b/packages/keepalive-ws/src/index.ts @@ -0,0 +1,2 @@ +export { KeepAliveClient } from "./client"; +export { KeepAliveServer } from "./server"; diff --git a/packages/keepalive-ws/src/server/command.ts b/packages/keepalive-ws/src/server/command.ts new file mode 100644 index 0000000..6381a42 --- /dev/null +++ b/packages/keepalive-ws/src/server/command.ts @@ -0,0 +1,19 @@ +export interface Command { + id?: number; + command: string; + payload: any; +} + +export const bufferToCommand = (buffer: Buffer): Command => { + const decoded = new TextDecoder("utf-8").decode(buffer); + if (!decoded) { + return { id: 0, command: "", payload: {} }; + } + + try { + const parsed = JSON.parse(decoded) as Command; + return { id: parsed.id, command: parsed.command, payload: parsed.payload }; + } catch (e) { + return { id: 0, command: "", payload: {} }; + } +}; diff --git a/packages/keepalive-ws/src/server/connection.ts b/packages/keepalive-ws/src/server/connection.ts new file mode 100644 index 0000000..db0a4ef --- /dev/null +++ b/packages/keepalive-ws/src/server/connection.ts @@ -0,0 +1,88 @@ +import EventEmitter from "node:events"; +import { IncomingMessage } from "node:http"; +import { WebSocket } from "ws"; +import { KeepAliveServerOptions } from "."; +import { bufferToCommand, Command } from "./command"; +import { Latency } from "./latency"; +import { Ping } from "./ping"; + +export class Connection extends EventEmitter { + id: string; + socket: WebSocket; + alive = true; + latency: Latency; + ping: Ping; + remoteAddress: string; + connectionOptions: KeepAliveServerOptions; + + constructor( + socket: WebSocket, + req: IncomingMessage, + options: KeepAliveServerOptions, + ) { + super(); + this.socket = socket; + this.id = req.headers["sec-websocket-key"]!; + this.remoteAddress = req.socket.remoteAddress!; + this.connectionOptions = options; + + this.applyListeners(); + this.startIntervals(); + } + + startIntervals() { + this.latency = new Latency(); + this.ping = new Ping(); + + this.latency.interval = setInterval(() => { + if (!this.alive) { + return; + } + + if (typeof this.latency.ms === "number") { + this.send({ command: "latency", payload: this.latency.ms }); + } + + this.latency.onRequest(); + this.send({ command: "latency:request", payload: {} }); + }, this.connectionOptions.latencyInterval); + + this.ping.interval = setInterval(() => { + if (!this.alive) { + this.emit("close"); + } + + this.alive = false; + this.send({ command: "ping", payload: {} }); + }, this.connectionOptions.pingInterval); + } + + stopIntervals() { + clearInterval(this.latency.interval); + clearInterval(this.ping.interval); + } + + applyListeners() { + this.socket.on("close", () => { + this.emit("close"); + }); + + this.socket.on("message", (buffer: Buffer) => { + const command = bufferToCommand(buffer); + + if (command.command === "latency:response") { + this.latency.onResponse(); + return; + } else if (command.command === "pong") { + this.alive = true; + return; + } + + this.emit("message", buffer); + }); + } + + send(cmd: Command) { + this.socket.send(JSON.stringify(cmd)); + } +} diff --git a/packages/keepalive-ws/src/server/index.ts b/packages/keepalive-ws/src/server/index.ts new file mode 100644 index 0000000..66c888d --- /dev/null +++ b/packages/keepalive-ws/src/server/index.ts @@ -0,0 +1,294 @@ +import { IncomingMessage } from "node:http"; +import { ServerOptions, WebSocket, WebSocketServer } from "ws"; +import { bufferToCommand } from "./command"; +import { Connection } from "./connection"; + +export declare interface KeepAliveServer extends WebSocketServer { + on(event: "connection", handler: (socket: WebSocket, req: IncomingMessage) => void): this; + on(event: "connected", handler: (c: Connection) => void): this; + on(event: "close", handler: (c: Connection) => void): this; + on(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; + on(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this; + on(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this; + + emit(event: "connection", socket: WebSocket, req: IncomingMessage): boolean; + emit(event: "connected", connection: Connection): boolean; + emit(event: "close", connection: Connection): boolean; + emit(event: "error", connection: Connection): boolean; + + once(event: "connection", cb: (this: WebSocketServer, socket: WebSocket, request: IncomingMessage) => void): this; + once(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; + once(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this; + once(event: "close" | "listening", cb: (this: WebSocketServer) => void): this; + once(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this; + + off(event: "connection", cb: (this: WebSocketServer, socket: WebSocket, request: IncomingMessage) => void): this; + off(event: "error", cb: (this: WebSocketServer, error: Error) => void): this; + off(event: "headers", cb: (this: WebSocketServer, headers: string[], request: IncomingMessage) => void): this; + off(event: "close" | "listening", cb: (this: WebSocketServer) => void): this; + off(event: string | symbol, listener: (this: WebSocketServer, ...args: any[]) => void): this; + + addListener(event: "connection", cb: (client: WebSocket, request: IncomingMessage) => void): this; + addListener(event: "error", cb: (err: Error) => void): this; + addListener(event: "headers", cb: (headers: string[], request: IncomingMessage) => void): this; + addListener(event: "close" | "listening", cb: () => void): this; + addListener(event: string | symbol, listener: (...args: any[]) => void): this; + + removeListener(event: "connection", cb: (client: WebSocket) => void): this; + removeListener(event: "error", cb: (err: Error) => void): this; + removeListener(event: "headers", cb: (headers: string[], request: IncomingMessage) => void): this; + removeListener(event: "close" | "listening", cb: () => void): this; + removeListener(event: string | symbol, listener: (...args: any[]) => void): this; +} +export class WSContext { + wss: KeepAliveServer; + connection: Connection; + payload: any; + + constructor(wss: KeepAliveServer, connection: Connection, payload: any) { + this.wss = wss; + this.connection = connection; + this.payload = payload; + } +} + + +export type SocketMiddleware = (c: WSContext) => any | Promise; + +export type KeepAliveServerOptions = ServerOptions & { + /** + * The interval at which to send ping messages to the client. + * @default 30000 + */ + pingInterval?: number; + + /** + * The interval at which to send both latency requests and updates to the client. + * @default 5000 + */ + latencyInterval?: number; +}; + +export class KeepAliveServer extends WebSocketServer { + connections: { [id: string]: Connection } = {}; + remoteAddressToConnections: { [address: string]: Connection[] } = {}; + commands: { [command: string]: (context: WSContext) => Promise } = {}; + globalMiddlewares: SocketMiddleware[] = []; + middlewares: { [key: string]: SocketMiddleware[] } = {}; + rooms: { [roomName: string]: Set } = {}; + declare serverOptions: KeepAliveServerOptions; + + constructor(opts: KeepAliveServerOptions) { + super({ ...opts }); + this.serverOptions = { + ...opts, + pingInterval: opts.pingInterval ?? 30_000, + latencyInterval: opts.latencyInterval ?? 5_000, + }; + this.applyListeners(); + } + + private cleanupConnection(c: Connection) { + c.stopIntervals(); + delete this.connections[c.id]; + if (this.remoteAddressToConnections[c.remoteAddress]) { + this.remoteAddressToConnections[c.remoteAddress] = this.remoteAddressToConnections[c.remoteAddress].filter( + (cn) => cn.id !== c.id + ); + } + + if (!this.remoteAddressToConnections[c.remoteAddress].length) { + delete this.remoteAddressToConnections[c.remoteAddress]; + } + } + + private applyListeners() { + this.on("connection", (socket: WebSocket, req: IncomingMessage) => { + const connection = new Connection(socket, req, this.serverOptions); + this.connections[connection.id] = connection; + + if (!this.remoteAddressToConnections[connection.remoteAddress]) { + this.remoteAddressToConnections[connection.remoteAddress] = []; + } + + this.remoteAddressToConnections[connection.remoteAddress].push(connection); + + + this.emit("connected", connection); + + connection.once("close", () => { + this.cleanupConnection(connection); + this.emit("close", connection); + + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + + Object.keys(this.rooms).forEach((roomName) => { + this.rooms[roomName].delete(connection.id); + }); + }); + + connection.on("message", (buffer: Buffer) => { + try { + const { id, command, payload } = bufferToCommand(buffer); + this.runCommand(id ?? 0, command, payload, connection); + } catch (e) { + this.emit("error", e); + } + }); + }); + } + + broadcast(command: string, payload: any, connections?: Connection[]) { + const cmd = JSON.stringify({ command, payload }); + + if (connections) { + connections.forEach((c) => { + c.socket.send(cmd); + }); + + return; + } + + Object.values(this.connections).forEach((c) => { + c.socket.send(cmd); + }); + } + + /** + * Given a Connection, broadcasts only to all other Connections that share + * the same connection.remoteAddress. + * + * Use cases: + * - Push notifications. + * - Auth changes, e.g., logging out in one tab should log you out in all tabs. + */ + broadcastRemoteAddress(c: Connection, command: string, payload: any) { + const cmd = JSON.stringify({ command, payload }); + this.remoteAddressToConnections[c.remoteAddress].forEach((cn) => { + cn.socket.send(cmd); + }); + } + + broadcastRemoteAddressById(id: string, command: string, payload: any) { + const connection = this.connections[id]; + if (connection) { + this.broadcastRemoteAddress(connection, command, payload); + } + } + + /** + * Given a roomName, a command and a payload, broadcasts to all Connections + * that are in the room. + */ + broadcastRoom(roomName: string, command: string, payload: any) { + const cmd = JSON.stringify({ command, payload }); + const room = this.rooms[roomName]; + + if (!room) return; + + room.forEach((connectionId) => { + const connection = this.connections[connectionId]; + if (connection) { + connection.socket.send(cmd); + } + }); + } + + /** + * Given a connection, broadcasts a message to all connections except + * the provided connection. + */ + broadcastExclude(connection: Connection, command: string, payload: any) { + const cmd = JSON.stringify({ command, payload }); + Object.values(this.connections).forEach((c) => { + if (c.id !== connection.id) { + c.socket.send(cmd); + } + }); + } + + /** + * @example + * ```typescript + * server.registerCommand("join:room", async (payload: { roomName: string }, connection: Connection) => { + * server.addToRoom(payload.roomName, connection); + * server.broadcastRoom(payload.roomName, "joined", { roomName: payload.roomName }); + * }); + * ``` + */ + addToRoom(roomName: string, connection: Connection) { + this.rooms[roomName] = this.rooms[roomName] ?? new Set(); + this.rooms[roomName].add(connection.id); + } + + removeFromRoom(roomName: string, connection: Connection) { + if (!this.rooms[roomName]) return; + this.rooms[roomName].delete(connection.id); + } + + /** + * Returns a "room", which is simply a Set of Connection ids. + * @param roomName + */ + getRoom(roomName: string): Connection[] { + const ids = this.rooms[roomName] || new Set(); + return Array.from(ids).map((id) => this.connections[id]); + } + + clearRoom(roomName: string) { + this.rooms[roomName] = new Set(); + } + + registerCommand(command: string, callback: SocketMiddleware, middlewares: SocketMiddleware[] = []) { + this.commands[command] = callback; + this.prependMiddlewareToCommand(command, middlewares); + } + + prependMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) { + if (middlewares.length) { + this.middlewares[command] = this.middlewares[command] || []; + this.middlewares[command] = middlewares.concat(this.middlewares[command]); + } + } + + appendMiddlewareToCommand(command: string, middlewares: SocketMiddleware[]) { + if (middlewares.length) { + this.middlewares[command] = this.middlewares[command] || []; + this.middlewares[command] = this.middlewares[command].concat(middlewares); + } + } + + private async runCommand(id: number, command: string, payload: any, connection: Connection) { + const c = new WSContext(this, connection, payload); + + try { + if (!this.commands[command]) { + // An onslaught of commands that don't exist is a sign of a bad + // or otherwise misconfigured client. + throw new Error(`Command [${command}] not found.`); + } + + if (this.globalMiddlewares.length) { + for (const mw of this.globalMiddlewares) { + await mw(c); + } + } + + if (this.middlewares[command]) { + for (const mw of this.middlewares[command]) { + await mw(c); + } + } + + const result = await this.commands[command](c); + connection.send({ id, command, payload: result }); + } catch (e) { + const payload = { error: e.message ?? e ?? "Unknown error" }; + connection.send({ id, command, payload }); + } + } +} + +export { Connection }; diff --git a/packages/keepalive-ws/src/server/latency.ts b/packages/keepalive-ws/src/server/latency.ts new file mode 100644 index 0000000..310d986 --- /dev/null +++ b/packages/keepalive-ws/src/server/latency.ts @@ -0,0 +1,15 @@ +export class Latency { + start = 0; + end = 0; + ms = 0; + interval: ReturnType; + + onRequest() { + this.start = Date.now(); + } + + onResponse() { + this.end = Date.now(); + this.ms = this.end - this.start; + } +} diff --git a/packages/keepalive-ws/src/server/ping.ts b/packages/keepalive-ws/src/server/ping.ts new file mode 100644 index 0000000..1a706a1 --- /dev/null +++ b/packages/keepalive-ws/src/server/ping.ts @@ -0,0 +1,3 @@ +export class Ping { + interval: ReturnType; +} diff --git a/packages/keepalive-ws/tsconfig.json b/packages/keepalive-ws/tsconfig.json new file mode 100644 index 0000000..b72a0fd --- /dev/null +++ b/packages/keepalive-ws/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "es2021", + "moduleResolution": "node", + "outDir": "./lib", + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "baseUrl": ".", + "declaration": true, + "declarationMap": true + }, + "exclude": ["node_modules", "lib"] +} diff --git a/packages/ms/.npmignore b/packages/ms/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/ms/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/ms/README.md b/packages/ms/README.md new file mode 100644 index 0000000..b04bd34 --- /dev/null +++ b/packages/ms/README.md @@ -0,0 +1,33 @@ +# ms + +[![NPM version](https://img.shields.io/npm/v/@prsm/ms?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/ms) + +Confusingly, not just for converting milliseconds. + +```typescript +import ms from "@prsm/ms"; + +ms(100); // 100 +ms("100"); // 100 + +ms("10s"); // 10_000 +ms("10sec"); // 10_000 +ms("10secs"); // 10_000 +ms("10second"); // 10_000 +ms("10,000,000seconds"); // 10_000_000_000 + +ms("0h"); // 0 +ms("10.9ms"); // 11 +ms("10.9ms", { round: false }); // 10.9 + +ms("1000.9ms", { round: false, unit: "s" }); // 1.0009 +ms("1000.9ms", { unit: "s" }); // 1 + +// All supported unit aliases: +// ms, msec, msecs, millisec, milliseconds +// s, sec, secs, second, seconds +// m, min, mins, minute, minutes +// h, hr, hrs, hour, hours +// d, day, days +// w, wk, wks, week, weeks +``` diff --git a/packages/ms/bump.config.ts b/packages/ms/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/ms/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/ms/bun.lockb b/packages/ms/bun.lockb new file mode 100755 index 0000000..415b550 Binary files /dev/null and b/packages/ms/bun.lockb differ diff --git a/packages/ms/package.json b/packages/ms/package.json new file mode 100644 index 0000000..ee12b8a --- /dev/null +++ b/packages/ms/package.json @@ -0,0 +1,26 @@ +{ + "name": "@prsm/ms", + "version": "1.0.1", + "author": "", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "description": "", + "keywords": [], + "license": "Apache-2.0", + "scripts": { + "build": "tsup", + "release": "bumpp package.json && npm publish --access public" + }, + "type": "module", + "devDependencies": { + "@types/node": "^22.4.1", + "bumpp": "^9.5.1", + "tsup": "^8.2.4" + } +} diff --git a/packages/ms/src/index.ts b/packages/ms/src/index.ts new file mode 100644 index 0000000..e003b22 --- /dev/null +++ b/packages/ms/src/index.ts @@ -0,0 +1,174 @@ +const MS_IN = { + w: 604_800_000, + wk: 604_800_000, + wks: 604_800_000, + week: 604_800_000, + weeks: 604_800_000, + d: 86_400_000, + dy: 86_400_000, + day: 86_400_000, + days: 86_400_000, + h: 3_600_000, + hr: 3_600_000, + hrs: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + m: 60_000, + mn: 60_000, + min: 60_000, + mins: 60_000, + minute: 60_000, + minutes: 60_000, + s: 1_000, + sec: 1_000, + secs: 1_000, + second: 1_000, + seconds: 1_000, + ms: 1, + msec: 1, + msecs: 1, + millisec: 1, + millisecond: 1, + milliseconds: 1, +}; + +const UNIT_ALIAS = { + w: "week", + wk: "week", + wks: "week", + week: "week", + weeks: "week", + d: "day", + dy: "day", + day: "day", + days: "day", + h: "hour", + hr: "hour", + hrs: "hour", + hour: "hour", + hours: "hour", + m: "minute", + mn: "minute", + min: "minute", + mins: "minute", + minute: "minute", + minutes: "minute", + s: "second", + sec: "second", + secs: "second", + second: "second", + seconds: "second", + ms: "ms", + msec: "ms", + msecs: "ms", + millisec: "ms", + millisecond: "ms", + milliseconds: "ms", +}; + +const msRegex = /(-?)([\d\s\-_,.]+)\s*([a-zA-Z]*)/g; +const sanitizeRegex = /[\s\-_,]/g; +const resultCache = {}; + +function isValid(input: any) { + return ( + (typeof input === "string" && input.length > 0) || + (typeof input === "number" && + input > -Infinity && + input < Infinity && + !isNaN(input)) + ); +} + +function ms(msString: any, defaultOrOptions: any = {}, options: any = {}) { + if (defaultOrOptions && typeof defaultOrOptions === "object") { + options = defaultOrOptions; + defaultOrOptions = 0; + } + + let defaultMsString = isValid(defaultOrOptions) ? defaultOrOptions : 0; + const { unit = "ms", round = true } = options; + + const cacheKey = `${msString}${defaultMsString}${unit}${round}`; + const cacheExists = cacheKey in resultCache; + + if (cacheExists) { + return resultCache[cacheKey]; + } + + // if defaultDuration is a string, it's something like "1day". we need to + // call ms() on it to get the number of milliseconds it represents. + if (typeof defaultMsString === "string") { + defaultMsString = ms(defaultMsString, 0); + } + + let parsed = parseMs(msString, defaultMsString); + + parsed = convertToUnit(parsed, unit); + parsed = applyRounding(parsed, round); + + if (!cacheExists) { + resultCache[cacheKey] = parsed; + } + + return parsed; +} + +function parseMs(msString: any, defaultMsString: number): number { + const ms = isValid(msString) ? msString : defaultMsString; + const re = new RegExp(msRegex); + + if (typeof ms === "string") { + let totalMs = 0; + + if (ms.length > 0) { + let matches: string[]; + let anyMatches = false; + + while ((matches = re.exec(ms)!)) { + anyMatches = true; + let value = parseFloat(matches[2].replace(sanitizeRegex, "")); + + if (matches[1]) { + value = -value; + } + + if (!isNaN(value)) { + const unitKey = UNIT_ALIAS[matches[3].toLowerCase()] || "ms"; + totalMs += value * MS_IN[unitKey]; + } + } + + if (!anyMatches) { + return defaultMsString ?? 0; + } + } + + return totalMs; + } + + return ms; +} + +function convertToUnit(ms: number, unit: string): number { + if (unit in MS_IN) { + ms /= MS_IN[unit]; + } else { + return 0; + } + + return ms; +} + +function applyRounding(ms: number, round: boolean): number { + if (ms !== 0 && round) { + ms = Math.round(ms); + if (ms === 0) { + return Math.abs(ms); + } + } + + return ms; +} + +export default ms; diff --git a/packages/ms/tsconfig.json b/packages/ms/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/ms/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/ms/tsup.config.ts b/packages/ms/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/ms/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +}); diff --git a/packages/otp/.npmignore b/packages/otp/.npmignore new file mode 100644 index 0000000..cd3ca40 --- /dev/null +++ b/packages/otp/.npmignore @@ -0,0 +1,2 @@ +node_modules +src diff --git a/packages/otp/README.md b/packages/otp/README.md new file mode 100644 index 0000000..2b19328 --- /dev/null +++ b/packages/otp/README.md @@ -0,0 +1,88 @@ +# @prsm/otp + +[![NPM version](https://img.shields.io/npm/v/@prsm/otp?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/otp) + +A simple and secure library for generating and verifying One-Time Passwords (OTPs) based on the TOTP algorithm. + +## Installation + +```bash +npm install @prsm/otp +``` + +## Usage + +### Create a Secret + +Generate a secret with different strengths: + +```typescript +import Otp from "@prsm/otp"; + +// Default (high) strength +const secret = Otp.createSecret(); + +// Low strength +const lowStrengthSecret = Otp.createSecret(Otp.SHARED_SECRET_STRENGTH_LOW); + +// Moderate strength +const moderateStrengthSecret = Otp.createSecret(Otp.SHARED_SECRET_STRENGTH_MODERATE); +``` + +For each user, store the secret securely and associate it with the user. When authenticating a user, you need to reference the secret that was generated for that user. The secret should be kept confidential and never shared. + +### Generate a TOTP + +```typescript +const secret = Otp.createSecret(); +const totp = Otp.generateTotp(secret); +console.log(totp); // A 6-digit TOTP + +const totp8 = Otp.generateTotp(secret, undefined, 8); +console.log(totp8); // An 8-digit TOTP +``` + +### Verify a TOTP + +```typescript +const isValid = Otp.verifyTotp(secret, totp); +console.log(isValid); // true, even if the TOTP is expired +``` + +For strict verification, you can specify the number of steps and the time window: + +```typescript +const isValidStrict = Otp.verifyTotp(secret, totp, 0, 0); +console.log(isValidStrict); // true only if the TOTP is valid at the current time +``` + +### Generate a TOTP URI for QR Code + +```typescript +const uri = Otp.createTotpKeyUriForQrCode("app.example.com", "john.doe@example.org", secret); +console.log(uri); // URI for QR code +``` + +### Custom Configuration + +Customize OTP length, interval, and hash function: + +```typescript +const customTotp = Otp.generateTotp(secret, undefined, 8, 60, undefined, Otp.HASH_FUNCTION_SHA_256); +const isValidCustom = Otp.verifyTotp(secret, customTotp, undefined, undefined, undefined, 8, 60, undefined, Otp.HASH_FUNCTION_SHA_256); +console.log(isValidCustom); // true +``` + +## Error Handling + +Handle specific errors: + +```typescript +try { + Otp.generateTotp("shortsecret"); +} catch (error) { + if (error instanceof Otp.InvalidSecretError) { + console.error("The provided secret is too short."); + } +} +``` diff --git a/packages/otp/bump.config.ts b/packages/otp/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/otp/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/otp/bun.lockb b/packages/otp/bun.lockb new file mode 100755 index 0000000..b56486d Binary files /dev/null and b/packages/otp/bun.lockb differ diff --git a/packages/otp/package.json b/packages/otp/package.json new file mode 100644 index 0000000..b16e3b4 --- /dev/null +++ b/packages/otp/package.json @@ -0,0 +1,33 @@ +{ + "name": "@prsm/otp", + "version": "1.0.0", + "description": "", + "main": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "vitest", + "release": "bumpp package.json && npm publish --access public" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "hi-base32": "^0.5.1" + }, + "devDependencies": { + "@types/qrcode": "^1.5.5", + "bumpp": "^9.5.2", + "qrcode": "^1.5.4", + "tsup": "^8.2.4", + "typescript": "^5.5.4", + "vitest": "^2.0.5" + } +} diff --git a/packages/otp/src/code.ts b/packages/otp/src/code.ts new file mode 100644 index 0000000..b6b11c4 --- /dev/null +++ b/packages/otp/src/code.ts @@ -0,0 +1,34 @@ +import QRCode from "qrcode"; +import Otp from "."; + +// Step 1: Create a secret +const secret = Otp.createSecret(); +console.log("Secret:", secret); + +// Step 2: Generate the TOTP URI +const issuer = "app.example.com"; +const accountName = "john.doe@example.org"; +const uri = Otp.createTotpKeyUriForQrCode(issuer, accountName, secret); +console.log("TOTP URI:", uri); + +// Step 3: Generate the QR code +QRCode.toDataURL(uri, (err, url) => { + if (err) { + console.error("Error generating QR code:", err); + return; + } + console.log("QR Code URL:", url); + + // If you are running this in a Node.js environment, you can save the QR code as an image file + // Uncomment the following lines to save the QR code as a PNG file + // QRCode.toFile('totp-qrcode.png', uri, (err) => { + // if (err) throw err; + // console.log('QR code saved as totp-qrcode.png'); + // }); + + // If you are running this in a browser environment, you can display the QR code in an element + // document.getElementById('qrcode').src = url; +}); + + +// Store the secret. You need to validate the secret against the TOTP generated by the user. diff --git a/packages/otp/src/index.test.ts b/packages/otp/src/index.test.ts new file mode 100644 index 0000000..8133229 --- /dev/null +++ b/packages/otp/src/index.test.ts @@ -0,0 +1,246 @@ +import * as base32 from "hi-base32"; +import { describe, expect, it } from "vitest"; +import Otp, { InvalidHashFunctionError, InvalidOtpLengthError, InvalidSecretError } from "./index"; + +describe("Otp", () => { + it("should generate a secret of default strength", () => { + const secret = Otp.createSecret(); + expect(secret).toBeDefined(); + expect(secret.length).toBe(32); + }); + + it("should generate a secret of low strength", () => { + const secret = Otp.createSecret(Otp.SHARED_SECRET_STRENGTH_LOW); + expect(secret).toBeDefined(); + expect(secret.length).toBe(16); + }); + + it("should generate a secret of moderate strength", () => { + const secret = Otp.createSecret(Otp.SHARED_SECRET_STRENGTH_MODERATE); + expect(secret).toBeDefined(); + expect(secret.length).toBe(26); + }); + + it("should generate a secret of high strength", () => { + const secret = Otp.createSecret(Otp.SHARED_SECRET_STRENGTH_HIGH); + expect(secret).toBeDefined(); + expect(secret.length).toBe(32); + }); + + it("should generate a valid TOTP", () => { + const secret = Otp.createSecret(); + const totp = Otp.generateTotp(secret); + expect(totp).toBeDefined(); + expect(totp.length).toBe(Otp.OTP_LENGTH_DEFAULT); + }); + + it("should verify a valid TOTP", () => { + const secret = Otp.createSecret(); + const totp = Otp.generateTotp(secret); + const isValid = Otp.verifyTotp(secret, totp); + expect(isValid).toBe(true); + }); + + it("should not verify an invalid TOTP", () => { + const secret = Otp.createSecret(); + const isValid = Otp.verifyTotp(secret, "123456"); + expect(isValid).toBe(false); + }); + + it("should generate a valid TOTP with custom length", () => { + const secret = Otp.createSecret(); + const otpLength = 8; + const totp = Otp.generateTotp(secret, undefined, otpLength); + expect(totp).toBeDefined(); + expect(totp.length).toBe(otpLength); + }); + + it("should verify a valid TOTP with custom length", () => { + const secret = Otp.createSecret(); + const otpLength = 8; + const totp = Otp.generateTotp(secret, undefined, otpLength); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, undefined, otpLength); + expect(isValid).toBe(true); + }); + + it("should generate a valid TOTP URI for QR code", () => { + const secret = Otp.createSecret(); + const uri = Otp.createTotpKeyUriForQrCode("app.example.com", "john.doe@example.org", secret); + expect(uri).toBeDefined(); + expect(uri).toContain("otpauth://totp/app.example.com:john.doe%40example.org"); + expect(uri).toContain(`secret=${secret}`); + expect(uri).toContain("issuer=app.example.com"); + }); + + it("should throw an error for invalid secret length", () => { + expect(() => { + Otp.generateTotp("shortsecret"); + }).toThrow(InvalidSecretError); + }); + + it("should throw an error for invalid OTP length", () => { + const secret = Otp.createSecret(); + expect(() => { + Otp.generateTotp(secret, undefined, 5); + }).toThrow(InvalidOtpLengthError); + expect(() => { + Otp.generateTotp(secret, undefined, 9); + }).toThrow(InvalidOtpLengthError); + }); + + it("should throw an error for invalid hash function", () => { + const secret = Otp.createSecret(); + expect(() => { + Otp.generateTotp(secret, undefined, undefined, undefined, undefined, 999); + }).toThrow(InvalidHashFunctionError); + }); + + it("should generate and verify TOTP with custom time and interval", () => { + const secret = Otp.createSecret(); + const customTime = Math.floor(Date.now() / 1000) - 100; + const customInterval = 60; + const totp = Otp.generateTotp(secret, customTime, undefined, customInterval); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, customTime, undefined, customInterval); + expect(isValid).toBe(true); + }); + + it("should handle edge case for time steps", () => { + const secret = Otp.createSecret(); + const currentTime = Math.floor(Date.now() / 1000); + const interval = Otp.INTERVAL_LENGTH_DEFAULT; + const boundaryTime = currentTime - (currentTime % interval); + const totp = Otp.generateTotp(secret, boundaryTime); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, boundaryTime); + expect(isValid).toBe(true); + }); + + it("should not verify an expired TOTP", () => { + const secret = Otp.createSecret(); + const pastTime = Math.floor(Date.now() / 1000) - 300; // 5 minutes ago + const totp = Otp.generateTotp(secret, pastTime); + const isValid = Otp.verifyTotp(secret, totp); + expect(isValid).toBe(false); + }); + + it("should not verify a future TOTP", () => { + const secret = Otp.createSecret(); + const futureTime = Math.floor(Date.now() / 1000) + 300; // 5 minutes in the future + const totp = Otp.generateTotp(secret, futureTime); + const isValid = Otp.verifyTotp(secret, totp); + expect(isValid).toBe(false); + }); + + it("should verify RFC 6238 test vectors for SHA-1", () => { + const rfc6238TestKeySha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // Base32 encoding of '12345678901234567890' + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 3, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 2, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 2, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 2, 2, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 0, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 2, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 0, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 3, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "94287082", 0, 2, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + }); + + it("should verify RFC 6238 test vectors for SHA-256", () => { + const rfc6238TestKeySha256 = base32.encode("12345678901234567890123456789012"); // 12345678901234567890123456789012 + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 3, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 2, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 2, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 2, 2, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 0, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 2, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 0, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 3, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha256, "46119246", 0, 2, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_256)).toBe(false); + }); + + it("should verify RFC 6238 test vectors for SHA-512", () => { + const rfc6238TestKeySha512 = base32.encode("1234567890123456789012345678901234567890123456789012345678901234"); // 1234567890123456789012345678901234567890123456789012345678901234 + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 3, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 2, 0, 59 + 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 2, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 0, 59 + 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 2, 2, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 0, 59, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 2, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 0, 59 - 60, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 3, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha512, "90693936", 0, 2, 59 - 90, 8, 30, 0, Otp.HASH_FUNCTION_SHA_512)).toBe(false); + }); +}); + +describe("Otp - 6 Character Length", () => { + it("should generate a valid 6-character TOTP", () => { + const secret = Otp.createSecret(); + const totp = Otp.generateTotp(secret, undefined, 6); + expect(totp).toBeDefined(); + expect(totp.length).toBe(6); + }); + + it("should verify a valid 6-character TOTP", () => { + const secret = Otp.createSecret(); + const totp = Otp.generateTotp(secret, undefined, 6); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, undefined, 6); + expect(isValid).toBe(true); + }); + + it("should not verify an invalid 6-character TOTP", () => { + const secret = Otp.createSecret(); + const isValid = Otp.verifyTotp(secret, "123456", undefined, undefined, undefined, 6); + expect(isValid).toBe(false); + }); + + it("should generate and verify 6-character TOTP with custom time and interval", () => { + const secret = Otp.createSecret(); + const customTime = Math.floor(Date.now() / 1000) - 100; + const customInterval = 60; + const totp = Otp.generateTotp(secret, customTime, 6, customInterval); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, customTime, 6, customInterval); + expect(isValid).toBe(true); + }); + + it("should handle edge case for 6-character OTP time steps", () => { + const secret = Otp.createSecret(); + const currentTime = Math.floor(Date.now() / 1000); + const interval = Otp.INTERVAL_LENGTH_DEFAULT; + const boundaryTime = currentTime - (currentTime % interval); + const totp = Otp.generateTotp(secret, boundaryTime, 6); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, boundaryTime, 6); + expect(isValid).toBe(true); + }); + + it("should not verify an expired 6-character TOTP", () => { + const secret = Otp.createSecret(); + const pastTime = Math.floor(Date.now() / 1000) - 300; // 5 minutes ago + const totp = Otp.generateTotp(secret, pastTime, 6); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, undefined, 6); + expect(isValid).toBe(false); + }); + + it("should not verify a future 6-character TOTP", () => { + const secret = Otp.createSecret(); + const futureTime = Math.floor(Date.now() / 1000) + 300; // 5 minutes in the future + const totp = Otp.generateTotp(secret, futureTime, 6); + const isValid = Otp.verifyTotp(secret, totp, undefined, undefined, undefined, 6); + expect(isValid).toBe(false); + }); + + it("should verify RFC 6238 test vectors for SHA-1 with 6-character OTP", () => { + // const rfc6238TestKeySha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // Base32 encoding of '12345678901234567890' + const rfc6238TestKeySha1 = base32.encode("12345678901234567890"); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 3, 0, 59 + 90, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 2, 0, 59 + 90, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 2, 0, 59 + 60, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 0, 59 + 60, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 2, 2, 59, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 0, 59, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 2, 59 - 60, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 0, 59 - 60, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 3, 59 - 90, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(true); + expect(Otp.verifyTotp(rfc6238TestKeySha1, "287082", 0, 2, 59 - 90, 6, 30, 0, Otp.HASH_FUNCTION_SHA_1)).toBe(false); + }); +}); diff --git a/packages/otp/src/index.ts b/packages/otp/src/index.ts new file mode 100644 index 0000000..70f3c7f --- /dev/null +++ b/packages/otp/src/index.ts @@ -0,0 +1,186 @@ +import * as base32 from "hi-base32"; +import * as crypto from "node:crypto"; + +export class InvalidOtpLengthError extends Error {} +export class InvalidSecretError extends Error {} +export class InvalidHashFunctionError extends Error {} +export class InvalidSecretStrengthError extends Error {} + +class Otp { + static OTP_LENGTH_MIN = 6; + static OTP_LENGTH_MAX = 8; + static OTP_LENGTH_DEFAULT = 6; + static INTERVAL_LENGTH_DEFAULT = 30; + static EPOCH_DEFAULT = 0; + static HASH_FUNCTION_SHA_1 = 1; + static HASH_FUNCTION_SHA_256 = 2; + static HASH_FUNCTION_SHA_512 = 3; + static HASH_FUNCTION_DEFAULT = Otp.HASH_FUNCTION_SHA_1; + static SHARED_SECRET_STRENGTH_LOW = 1; + static SHARED_SECRET_STRENGTH_MODERATE = 2; + static SHARED_SECRET_STRENGTH_HIGH = 3; + + /** + * Generates a shared secret using a specified strength. + * + * @param {number} strength - The strength of the shared secret, defaulting to Otp.SHARED_SECRET_STRENGTH_HIGH. + * This determines the number of bits used to generate the secret. + * @returns {string} - A base32 encoded string representing the generated shared secret. + * @throws {Error} - If the strength parameter is invalid or if there is an issue generating random bytes. + */ + static createSecret(strength: number = Otp.SHARED_SECRET_STRENGTH_HIGH): string { + const bits = this.determineBitsForSharedSecretStrength(strength); + const bytes = Math.ceil(bits / 8); + const buffer = crypto.randomBytes(bytes); + return base32.encode(buffer).replace(/=+$/, ""); + } + + /** + * Generates a TOTP (Time-based One-Time Password) Key URI for use in QR code generation. + * This URI can be scanned by authenticator apps like Google Authenticator or Authy. + * + * @param {string} issuer - The name of the service or organization issuing the OTP. + * @param {string} accountName - The account name or email address associated with the OTP. + * @param {string} secret - The shared secret key used for generating the OTP. + * @returns {string} - A URI formatted according to the otpauth URI scheme. + * @throws {Error} - Throws an error if any of the parameters are invalid or missing. + */ + static createTotpKeyUriForQrCode(issuer: string, accountName: string, secret: string): string { + return `otpauth://totp/${issuer}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${issuer}`; + } + + /** + * Generates a Time-based One-Time Password (TOTP) using the provided secret and parameters. + * + * @param {string} secret - The shared secret key used for generating the TOTP. Must be at least 16 characters long. + * @param {number} [t=Math.floor(Date.now() / 1000)] - The current Unix time in seconds. Defaults to the current time. + * @param {number} [otpLength=Otp.OTP_LENGTH_DEFAULT] - The desired length of the OTP. Must be between Otp.OTP_LENGTH_MIN and Otp.OTP_LENGTH_MAX. + * @param {number} [t_x=Otp.INTERVAL_LENGTH_DEFAULT] - The time step in seconds. Defaults to Otp.INTERVAL_LENGTH_DEFAULT. + * @param {number} [t_0=Otp.EPOCH_DEFAULT] - The Unix time to start counting time steps. Defaults to Otp.EPOCH_DEFAULT. + * @param {number} [hashFunction=Otp.HASH_FUNCTION_DEFAULT] - The hash function to use (e.g., Otp.HASH_FUNCTION_SHA_1, Otp.HASH_FUNCTION_SHA_256, Otp.HASH_FUNCTION_SHA_512). Defaults to Otp.HASH_FUNCTION_DEFAULT. + * @returns {string} - The generated TOTP as a string of digits, padded to the specified length. + * @throws {InvalidOtpLengthError} - If the specified OTP length is not within the valid range. + * @throws {InvalidSecretError} - If the provided secret is less than 16 characters long. + * @throws {InvalidHashFunctionError} - If the specified hash function is not supported. + */ + static generateTotp( + secret: string, + t: number = Math.floor(Date.now() / 1000), + otpLength: number = Otp.OTP_LENGTH_DEFAULT, + t_x: number = Otp.INTERVAL_LENGTH_DEFAULT, + t_0: number = Otp.EPOCH_DEFAULT, + hashFunction: number = Otp.HASH_FUNCTION_DEFAULT, + ): string { + if (otpLength < Otp.OTP_LENGTH_MIN || otpLength > Otp.OTP_LENGTH_MAX) { + throw new InvalidOtpLengthError(); + } + + secret = secret ? secret : ""; + t = t ? t : Math.floor(Date.now() / 1000); + t_x = t_x ? t_x : Otp.INTERVAL_LENGTH_DEFAULT; + t_0 = t_0 ? t_0 : Otp.EPOCH_DEFAULT; + + const c_t = Math.max(0, Math.floor((t - t_0) / t_x)); // Ensure c_t is non-negative + + secret = secret.replace(/[^A-Za-z2-7]/g, "").toUpperCase(); + + if (secret.length < 16) { + throw new InvalidSecretError(); + } + + const k = base32.decode.asBytes(secret); + + const counter64BitBigEndian = Buffer.alloc(8); + counter64BitBigEndian.writeUInt32BE(Math.floor(c_t / Math.pow(2, 32)), 0); + counter64BitBigEndian.writeUInt32BE(c_t % Math.pow(2, 32), 4); + + let hashFunctionNameForHmac: string; + switch (hashFunction) { + case Otp.HASH_FUNCTION_SHA_1: + hashFunctionNameForHmac = "sha1"; + break; + case Otp.HASH_FUNCTION_SHA_256: + hashFunctionNameForHmac = "sha256"; + break; + case Otp.HASH_FUNCTION_SHA_512: + hashFunctionNameForHmac = "sha512"; + break; + default: + throw new InvalidHashFunctionError(); + } + + const hmac = crypto.createHmac(hashFunctionNameForHmac, Buffer.from(k)); + hmac.update(counter64BitBigEndian); + const mac = hmac.digest(); + + const offset = mac[mac.length - 1] & 0x0f; + const macSubstring4Bytes = mac.slice(offset, offset + 4); + + const integer32Bit = macSubstring4Bytes.readUInt32BE(0) & 0x7fffffff; + + const hotp = integer32Bit % Math.pow(10, otpLength); + + return hotp.toString().padStart(otpLength, "0"); + } + + /** + * Verifies a Time-based One-Time Password (TOTP) against a given secret. + * + * @param {string} secret - The shared secret key used to generate the TOTP. + * @param {string} otpValue - The TOTP value to be verified. + * @param {number} [lookBehindSteps=2] - The number of time steps to look behind for a valid TOTP. + * @param {number} [lookAheadSteps=2] - The number of time steps to look ahead for a valid TOTP. + * @param {number} [t=Math.floor(Date.now() / 1000)] - The current Unix time in seconds. + * @param {number} [otpLength=Otp.OTP_LENGTH_DEFAULT] - The expected length of the TOTP. + * @param {number} [t_x=Otp.INTERVAL_LENGTH_DEFAULT] - The time step interval in seconds. + * @param {number} [t_0=Otp.EPOCH_DEFAULT] - The Unix epoch to start counting time steps from. + * @param {number} [hashFunction=Otp.HASH_FUNCTION_DEFAULT] - The hash function to use for generating the TOTP. + * @returns {boolean} - Returns true if the TOTP is valid, false otherwise. + * @throws {Error} - Throws an error if the OTP value length is not within the valid range. + */ + static verifyTotp( + secret: string, + otpValue: string, + lookBehindSteps: number = 2, + lookAheadSteps: number = 2, + t: number = Math.floor(Date.now() / 1000), + otpLength: number = Otp.OTP_LENGTH_DEFAULT, + t_x: number = Otp.INTERVAL_LENGTH_DEFAULT, + t_0: number = Otp.EPOCH_DEFAULT, + hashFunction: number = Otp.HASH_FUNCTION_DEFAULT, + ): boolean { + otpValue = otpValue.replace(/[^0-9]/g, ""); + + if (otpValue.length < Otp.OTP_LENGTH_MIN || otpValue.length > Otp.OTP_LENGTH_MAX) { + return false; + } + + if (otpValue.length !== otpLength) { + return false; + } + + for (let s = -lookBehindSteps; s <= lookAheadSteps; s++) { + const expectedOtpValue = this.generateTotp(secret, t + t_x * s, otpLength, t_x, t_0, hashFunction); + if (crypto.timingSafeEqual(Buffer.from(expectedOtpValue), Buffer.from(otpValue))) { + return true; + } + } + + return false; + } + + private static determineBitsForSharedSecretStrength(strength: number): number { + switch (strength) { + case 1: + return 80; + case 2: + return 128; + case 3: + return 160; + default: + throw new InvalidSecretStrengthError(); + } + } +} + +export default Otp; diff --git a/packages/otp/src/validate.ts b/packages/otp/src/validate.ts new file mode 100644 index 0000000..2392202 --- /dev/null +++ b/packages/otp/src/validate.ts @@ -0,0 +1,8 @@ +import Otp from "."; + +const totp = "355435"; // Replace with the TOTP generated by your authenticator app +// Loose validation +// const isValid = Otp.verifyTotp("IYKZRIYTTWVKXBDNG3VSY3FTQFEO3MWY", totp); +// Strict validation +const isValid = Otp.verifyTotp("IYKZRIYTTWVKXBDNG3VSY3FTQFEO3MWY", totp, 0, 0); +console.log("Is TOTP valid?", isValid); diff --git a/packages/otp/tsconfig.json b/packages/otp/tsconfig.json new file mode 100644 index 0000000..7fc5833 --- /dev/null +++ b/packages/otp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "dist", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "dist" + } +} diff --git a/packages/otp/tsup.config.ts b/packages/otp/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/otp/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +});