diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a9d35..45da14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [2.0.0] - 2024-02-09 +- Add support for codepage 852 enabling eastern european characters in usernames - Drop support for Node.js versions below 18 - Ecmascript only - Migrate to vitest and node:test for testing diff --git a/README.md b/README.md index bb043b4..f2a2acf 100644 --- a/README.md +++ b/README.md @@ -54,33 +54,35 @@ These examples are for [Express](https://expressjs.com/), but the functionality Add the dependency and create a simple middleware: ```javascript -let ltpa = require("ltpa") -ltpa.setSecrets({ - "example.com": "AAECAwQFBgcICQoLDA0ODxAREhM=", +import { getUserName, refresh, setSecrets } from 'ltpa' +import { NextFunction, Request, Response } from 'express' + +setSecrets({ + 'example.com': 'AAECAwQFBgcICQoLDA0ODxAREhM=', }) -/*** +/** * Express Middleware * Authenticate user by verifying the provided LtpaToken cookie */ -function mwLtpaAuth(req, res, next) { +export function mwLtpaAuth(req: Request, res: Response, next: NextFunction) { try { - let ltpaToken = ltpa.refresh(req.cookies.LtpaToken, "example.com") - let newCookie = - "LtpaToken=" + ltpaToken + "; Path=/; Domain=" + "example.com" - res.setHeader("Set-Cookie", newCookie) + const ltpaToken = refresh(req.cookies.LtpaToken, 'example.com') + const newCookie = + 'LtpaToken=' + ltpaToken + '; Path=/; Domain=' + 'example.com' + res.setHeader('Set-Cookie', newCookie) next() } catch (err) { console.log(err) - res.status(401).json({ message: "Not authorized for this resource" }) + res.status(401).json({ message: 'Not authorized for this resource' }) } } -/*** +/** * Express route */ -router.get("/testAuth", mwLtpaAuth, function (req, res) { - res.send("user is logged in as " + ltpa.getUserName(req.cookies.LtpaToken)) +router.get('/testAuth', mwLtpaAuth, (req: Request, res: Response) => { + res.send('user is logged in as ' + getUserName(req.cookies.LtpaToken)) }) ``` @@ -89,34 +91,32 @@ router.get("/testAuth", mwLtpaAuth, function (req, res) { If you need to access a backend Domino database using a specific user account, you can generate an LtpaToken for that account using the `generate` method: -```javascript -let ltpa = require("ltpa") -let rp = require("request-promise") +```typescript +import { Request, Response } from 'express' +import { generate, generateUserNameBuf, setSecrets } from 'ltpa' -ltpa.setSecrets({ - "example.com": "AAECAwQFBgcICQoLDA0ODxAREhM=", +setSecrets({ + 'example.com': 'AAECAwQFBgcICQoLDA0ODxAREhM=', }) -router.get("/myDominoView", function (req, res) { - let userNameBuf = ltpa.generateUserNameBuf("Sysadmin Account") - let backendToken = ltpa.generate(userNameBuf, "example.com") - - let dominoRequest = { - uri: "https://domino.example.com/api/data/collections/name/myDominoView", - method: "GET", - strictSSL: true, - timeout: 30000, - headers: { - Cookie: "LtpaToken=" + backendToken, - }, - } +router.get('/myDominoView', async (req: Request, res: Response) => { + const userNameBuf = generateUserNameBuf('CN=Sysadmin Account,O=Example Inc') + const backendToken = generate(userNameBuf, 'example.com') + + const url = new URL( + '/api/data/collections/name/myDominoView', + 'https://domino.example.com/', + ) + const headers = { Cookie: `LtpaToken=${backendToken}` } - rp(dominoRequest) - .then((response) => res.json(response)) - .catch((err) => { - console.log(err) - res.status(500).send(err) - }) + try { + const response = await fetch(url, { headers }) + const json = await response.json() + res.json(json) + } catch (err) { + console.error(err) + res.status(500).send(err) + } }) ``` @@ -138,9 +138,9 @@ $ npm run test:watch When validating token expiration, the library will only respect its internal `validity` setting, and will disregard the expiration-date setting in provided tokens. To force the library to use the actual timestamp in the token, use the setStrictExpirationValidation() method. This behaviour might change in version 2. -### Character set +### Character sets -The module only works with usernames containing characters in the `ibm850` codepage (basically Latin-1). The username in the token _should be_ encoded in an IBM proprietary format called `LMBCS` (Lotus Multi-Byte Character Set) for which I have found no javascript implementation. However, `LMBCS` is backwards compatible with `ibm850` for all characters in that codepage so if your usernames don't contain characters outside of `ibm850`, then you're good to go. +The module only works with usernames containing characters in the `ibm850`, and `ibm852` codepages (this covers most of Europe). The username in the token is encoded in an old IBM/Lotus format called [`LMBCS` (Lotus Multi-Byte Character Set)](https://en.wikipedia.org/wiki/Lotus_Multi-Byte_Character_Set) for which I have found no javascript implementation. ### LTPA1 only diff --git a/index.ts b/index.ts index 7ab5f5e..e938dca 100644 --- a/index.ts +++ b/index.ts @@ -26,6 +26,15 @@ let validity = 5400 let gracePeriod = 300 let strictExpirationValidation = false +/** + * Special handling of Codepage 852 + */ +const ibm852Chars = + 'ÇüéâäůćçłëŐőîŹÄĆÉĹĺôöĽľŚśÖÜŤťŁčáíóúĄąŽžĘ꬟ȺÁÂĚŞŻżĂăđĐĎËďŇÍÎěŢŮÓßÔŃńňŠšŔÚŕŰýÝţűŘř'.split( + '', + ) +const buf852 = Buffer.from([0x06]) + /*** * Set how long a generated token is valid. Default is 5400 seconds (90 minutes) * @param {number} seconds Number of seconds that tokens are valid @@ -65,11 +74,20 @@ function setSecrets(secrets: Secrets) { /*** * Generate a userName Buffer. Currently hardcoded to CP-850, but the * true char encoding is LMBCS - * @param {string} userName The username to be converted to a CP-850 buffer + * @param {string} username The username to be converted to a CP-850 buffer * @returns {Buffer} Username encoded in CP-850 and stuffed into a Buffer */ -function generateUserNameBuf(userName: string): Buffer { - return iconv.encode(userName, 'ibm850') +function generateUserNameBuf(username: string): Buffer { + const bufUsername = username.split('').reduce((acc, char) => { + if (ibm852Chars.includes(char)) { + const bufChar = iconv.encode(char, 'ibm852') + return Buffer.concat([acc, buf852, bufChar]) + } + const bufChar = iconv.encode(char, 'ibm850') + return Buffer.concat([acc, bufChar]) + }, Buffer.from('')) + + return bufUsername } /*** @@ -79,7 +97,11 @@ function generateUserNameBuf(userName: string): Buffer { * @param {number} timeStart Timestamp (seconds) for when the token validity should start. Default: now * @returns {string} The LtpaToken encoded as Base64 */ -function generate(userNameBuf: Buffer, domain: string, timeStart?: number) { +function generate( + userNameBuf: Buffer, + domain: string, + timeStart?: number, +): string { const start = timeStart ? timeStart : Math.floor(Date.now() / 1000) const timeCreation = (start - gracePeriod).toString(16) @@ -181,7 +203,7 @@ function validate(token: string, domain: string): void { function getUserNameBuf(token: string): Buffer { const size = Buffer.byteLength(token, 'base64') const ltpaToken = Buffer.alloc(size, token, 'base64') - return ltpaToken.slice(20, ltpaToken.length - 20) + return ltpaToken.subarray(20, ltpaToken.length - 20) } /*** @@ -190,7 +212,21 @@ function getUserNameBuf(token: string): Buffer { * @returns {string} Username as a UTF-8 string */ function getUserName(token: string): string { - return iconv.decode(getUserNameBuf(token), 'ibm850') + const bufUsername = getUserNameBuf(token) + let username: string[] = [] + for (let i = 0; i < bufUsername.length; i++) { + const char = bufUsername.subarray(i, i + 1) + if (char.equals(buf852)) { + const utf8 = iconv.decode(bufUsername.subarray(i + 1, i + 2), 'ibm852') + username.push(utf8) + i++ + } else { + const utf8 = iconv.decode(char, 'ibm850') + username.push(utf8) + } + } + + return username.join('') } /*** diff --git a/package.json b/package.json index 4ac8c56..b01db6b 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "ltpa", - "version": "2.0.0", + "version": "2.0.0-alpha.0", "description": "Ltpa token generation and validation", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", "exports": "./dist/index.js", "scripts": { - "build": "rm -fr dist && tsc", + "build": "npm run clean && tsc", "clean": "rm -fr dist", + "watch": "npm run clean && tsc -w", "prepublishOnly": "npm run build", "test": "vitest run --coverage", "test:watch": "vitest watch", diff --git a/test/index.spec.ts b/test/index.spec.ts index 557900c..f40f010 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -255,4 +255,30 @@ describe('Ltpa', function () { ) }) }) + + describe('codepage handling', () => { + it('should be able to convert an ascii username and back again', () => { + const username = 'my test username' + const buf = ltpa.generateUserNameBuf(username) + const token = ltpa.generate(buf, 'example.com') + const backAgain = ltpa.getUserName(token) + assert.equal(username, backAgain) + }) + + it('should be able to convert an ibm852 username and back again', () => { + const username = 'Łuczak' + const buf = ltpa.generateUserNameBuf(username) + const token = ltpa.generate(buf, 'example.com') + const backAgain = ltpa.getUserName(token) + assert.equal(username, backAgain) + }) + + it('should be able to handle a username containing both ibm850 and ibm852 characters', () => { + const username = 'Måns Östen Łučzak' + const buf = ltpa.generateUserNameBuf(username) + const token = ltpa.generate(buf, 'example.com') + const backAgain = ltpa.getUserName(token) + assert.equal(username, backAgain) + }) + }) })