From 5c2e4305648f96238b3c92e309bbb755bf487bbb Mon Sep 17 00:00:00 2001 From: Phillip9587 Date: Wed, 12 Feb 2025 20:19:29 +0100 Subject: [PATCH 1/4] feat: add `auth.format` for formatting credentials --- README.md | 17 ++++++ package.json | 1 + src/format.bench.ts | 27 ++++++++++ src/format.spec.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 64 ++++++++++++++++++++++- 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/format.bench.ts create mode 100644 src/format.spec.ts diff --git a/README.md b/README.md index 2428623..e2e54ed 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ otherwise an object with `name` and `pass` properties. Parse a basic auth authorization header string. This will return an object with `name` and `pass` properties, or `undefined` if the string is invalid. +### auth.format(credentials) + +Format a credentials object with `name` and `pass` properties as a basic +auth authorization header string. + ## Example Pass a Node.js request object to the module export. If parsing fails @@ -60,6 +65,18 @@ var auth = require('basic-auth'); var user = auth.parse(req.getHeader('Proxy-Authorization')); ``` +A credentials object can be formatted with `auth.format` as +basic auth header string. + + + +```js +var auth = require('basic-auth'); +var credentials = { name: 'foo', pass: 'bar' }; +var authHeader = auth.format(credentials); +// => "Basic Zm9vOmJhcg==" +``` + ### With vanilla node.js http server ```js diff --git a/package.json b/package.json index dde152b..e18a931 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist/" ], "scripts": { + "bench": "vitest bench", "build": "ts-scripts build", "format": "ts-scripts format", "lint": "ts-scripts lint", diff --git a/src/format.bench.ts b/src/format.bench.ts new file mode 100644 index 0000000..71121fc --- /dev/null +++ b/src/format.bench.ts @@ -0,0 +1,27 @@ +import { bench, describe } from 'vitest'; +import { format } from './index'; + +describe('format', () => { + bench('format with simple credentials', () => { + const credentials = { name: 'user', pass: 'password' }; + format(credentials); + }); + + bench('format with long credentials', () => { + const credentials = { + name: 'verylongusernameforbasicauth', + pass: 'verylongpasswordwithmanycharactersforbenchmark', + }; + format(credentials); + }); + + bench('format with unicode credentials', () => { + const credentials = { name: 'jürgen', pass: 'pässwörd' }; + format(credentials); + }); + + bench('format with special characters', () => { + const credentials = { name: 'user@domain', pass: 'p@ss!word#123' }; + format(credentials); + }); +}); diff --git a/src/format.spec.ts b/src/format.spec.ts new file mode 100644 index 0000000..aa58a3e --- /dev/null +++ b/src/format.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, assert } from 'vitest'; +import { format } from './index'; + +describe('format(credentials)', function () { + describe('arguments', function () { + describe('credentials', function () { + it('should be required', function () { + assert.throws( + () => (format as any)(), + /argument credentials is required/, + ); + }); + + it('should accept credentials', function () { + const header = format({ name: 'foo', pass: 'bar' }); + assert.strictEqual(header, 'Basic Zm9vOmJhcg=='); + }); + + it('should reject null', function () { + assert.throws( + format.bind(null, null as any), + /argument credentials is required/, + ); + }); + + it('should reject a number', function () { + assert.throws( + format.bind(null, 42 as any), + /argument credentials is required/, + ); + }); + + it('should reject a string', function () { + assert.throws( + format.bind(null, '' as any), + /argument credentials is required/, + ); + }); + + it('should reject an object without name', function () { + assert.throws( + format.bind(null, { pass: 'bar' } as any), + /argument credentials is required to have name and pass properties/, + ); + }); + + it('should reject an object without pass', function () { + assert.throws( + format.bind(null, { name: 'foo' } as any), + /argument credentials is required to have name and pass properties/, + ); + }); + + it('should reject an object with non-string name', function () { + assert.throws( + format.bind(null, { name: 42, pass: 'bar' } as any), + /argument credentials is required to have name and pass properties/, + ); + }); + + it('should reject an object with non-string pass', function () { + assert.throws( + format.bind(null, { name: 'foo', pass: 42 } as any), + /argument credentials is required to have name and pass properties/, + ); + }); + + it('should reject userid containing colon', function () { + assert.throws( + format.bind(null, { name: 'foo:bar', pass: 'baz' }), + /must not contain a colon or control characters/, + ); + }); + + it('should reject control chars in userid', function () { + assert.throws( + format.bind(null, { name: 'foo\u0000bar', pass: 'baz' }), + /must not contain a colon or control characters/, + ); + }); + + it('should reject control chars in password', function () { + assert.throws( + format.bind(null, { name: 'foo', pass: 'bar\u007f' }), + /must not contain control characters/, + ); + }); + }); + }); + + describe('with valid credentials', function () { + it('should return header', function () { + const header = format({ name: 'foo', pass: 'bar' }); + assert.strictEqual(header, 'Basic Zm9vOmJhcg=='); + }); + }); + + describe('with empty password', function () { + it('should throw', function () { + assert.throws( + format.bind(null, { name: 'foo', pass: '' }), + /argument credentials.name and credentials.pass must not be empty/, + ); + }); + }); + + describe('with empty userid', function () { + it('should throw', function () { + assert.throws( + format.bind(null, { name: '', pass: 'pass' }), + /argument credentials.name and credentials.pass must not be empty/, + ); + }); + }); + + describe('with empty userid and pass', function () { + it('should throw', function () { + assert.throws( + format.bind(null, { name: '', pass: '' }), + /argument credentials.name and credentials.pass must not be empty/, + ); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6576a46..2f9ff07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,55 @@ export function parse(string: string): Credentials | undefined { return new CredentialsImpl(userPass[1], userPass[2]); } +/** + * Format Basic Authorization Header + * + * @param {Credentials} credentials + * @return {string} + * @public + */ +export function format(credentials: Credentials): string { + if (!credentials) { + throw new TypeError('argument credentials is required'); + } + + if (typeof credentials !== 'object') { + throw new TypeError('argument credentials is required to be an object'); + } + + if ( + typeof credentials.name !== 'string' || + typeof credentials.pass !== 'string' + ) { + throw new TypeError( + 'argument credentials is required to have name and pass properties', + ); + } + + if (credentials.name.length === 0 || credentials.pass.length === 0) { + throw new TypeError( + 'argument credentials.name and credentials.pass must not be empty', + ); + } + + if ( + credentials.name.includes(':') || // RFC 7617 disallows colon in username + CONTROL_CHARS_REGEXP.test(credentials.name) + ) { + throw new TypeError( + 'argument credentials.name must not contain a colon or control characters', + ); + } + + if (CONTROL_CHARS_REGEXP.test(credentials.pass)) { + throw new TypeError( + 'argument credentials.pass must not contain control characters', + ); + } + + return 'Basic ' + encodeBase64(credentials.name + ':' + credentials.pass); +} + /** * RegExp for basic auth credentials * @@ -71,14 +120,27 @@ const CREDENTIALS_REGEXP = const USER_PASS_REGEXP = /^([^:]*):(.*)$/; /** - * Decode base64 string. + * RegExp for RFC 5234 CTL characters (US-ASCII 0-31 and 127). * @private */ +const CONTROL_CHARS_REGEXP = /[\x00-\x1F\x7F]/; +/** + * Decode base64 string. + * @private + */ function decodeBase64(str: string): string { return Buffer.from(str, 'base64').toString(); } +/** + * Encode string to base64. + * @private + */ +function encodeBase64(str: string): string { + return Buffer.from(str, 'utf-8').toString('base64'); +} + class CredentialsImpl implements Credentials { name: string; pass: string; From 42a9f45ea9864d0b25fe9119f128f2828f05de9a Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Fri, 24 Apr 2026 01:30:01 +0200 Subject: [PATCH 2/4] resolve review comments --- README.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 789a320..78b9bbc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ const { parse } = require('basic-auth'); Parse a basic auth authorization header string. This will return an object with `name` and `pass` properties, or `undefined` if the string is invalid. -### auth.format(credentials) +### format(credentials) Format a credentials object with `name` and `pass` properties as a basic auth authorization header string. @@ -41,8 +41,6 @@ auth authorization header string. Pass a Basic auth header to the `parse()` method. If parsing fails `undefined` is returned, otherwise an object with `.name` and `.pass`. - - ```js const { parse } = require('basic-auth'); const user = parse(req.headers.authorization); @@ -51,8 +49,6 @@ const user = parse(req.headers.authorization); A header string from any other location can also be parsed for example a `Proxy-Authorization` header: - - ```js const { parse } = require('basic-auth'); const user = parse(req.getHeader('Proxy-Authorization')); @@ -61,12 +57,10 @@ const user = parse(req.getHeader('Proxy-Authorization')); A credentials object can be formatted with `auth.format` as basic auth header string. - - ```js -var auth = require('basic-auth'); -var credentials = { name: 'foo', pass: 'bar' }; -var authHeader = auth.format(credentials); +const { format } = require('basic-auth'); +const credentials = { name: 'foo', pass: 'bar' }; +const authHeader = format(credentials); // => "Basic Zm9vOmJhcg==" ``` From 99945717ac58122e94a4655c8428814fd68f8035 Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Fri, 24 Apr 2026 01:30:35 +0200 Subject: [PATCH 3/4] remove another missed eslint comment --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 78b9bbc..84b476d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ $ npm install basic-auth ## API - - ```js const { parse } = require('basic-auth'); ``` From af1d90bc4f3d6b56ac5e322db8f6c4b98e903cdd Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Mon, 27 Apr 2026 09:15:22 +0200 Subject: [PATCH 4/4] remove empty checks --- src/format.spec.ts | 18 ++++++------------ src/index.ts | 6 ------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/format.spec.ts b/src/format.spec.ts index aa58a3e..158d419 100644 --- a/src/format.spec.ts +++ b/src/format.spec.ts @@ -97,28 +97,22 @@ describe('format(credentials)', function () { describe('with empty password', function () { it('should throw', function () { - assert.throws( - format.bind(null, { name: 'foo', pass: '' }), - /argument credentials.name and credentials.pass must not be empty/, - ); + const header = format({ name: 'foo', pass: '' }); + assert.strictEqual(header, 'Basic Zm9vOg=='); }); }); describe('with empty userid', function () { it('should throw', function () { - assert.throws( - format.bind(null, { name: '', pass: 'pass' }), - /argument credentials.name and credentials.pass must not be empty/, - ); + const header = format({ name: '', pass: 'pass' }); + assert.strictEqual(header, 'Basic OnBhc3M='); }); }); describe('with empty userid and pass', function () { it('should throw', function () { - assert.throws( - format.bind(null, { name: '', pass: '' }), - /argument credentials.name and credentials.pass must not be empty/, - ); + const header = format({ name: '', pass: '' }); + assert.strictEqual(header, 'Basic Og=='); }); }); }); diff --git a/src/index.ts b/src/index.ts index bb28196..3289d9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,12 +69,6 @@ export function format(credentials: Credentials): string { ); } - if (credentials.name.length === 0 || credentials.pass.length === 0) { - throw new TypeError( - 'argument credentials.name and credentials.pass must not be empty', - ); - } - if ( credentials.name.includes(':') || // RFC 7617 disallows colon in username CONTROL_CHARS_REGEXP.test(credentials.name)