diff --git a/README.md b/README.md index caf8a51..84b476d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ $ npm install basic-auth ## API - - ```js const { parse } = require('basic-auth'); ``` @@ -31,13 +29,16 @@ 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. +### format(credentials) + +Format a credentials object with `name` and `pass` properties as a basic +auth authorization header string. + ## Example 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); @@ -46,13 +47,21 @@ 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')); ``` +A credentials object can be formatted with `auth.format` as +basic auth header string. + +```js +const { format } = require('basic-auth'); +const credentials = { name: 'foo', pass: 'bar' }; +const authHeader = format(credentials); +// => "Basic Zm9vOmJhcg==" +``` + ### With vanilla node.js http server ```js 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..158d419 --- /dev/null +++ b/src/format.spec.ts @@ -0,0 +1,118 @@ +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 () { + const header = format({ name: 'foo', pass: '' }); + assert.strictEqual(header, 'Basic Zm9vOg=='); + }); + }); + + describe('with empty userid', function () { + it('should throw', function () { + const header = format({ name: '', pass: 'pass' }); + assert.strictEqual(header, 'Basic OnBhc3M='); + }); + }); + + describe('with empty userid and pass', function () { + it('should throw', function () { + const header = format({ name: '', pass: '' }); + assert.strictEqual(header, 'Basic Og=='); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7132042..3289d9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,49 @@ export function parse(string: string): Credentials | undefined { }; } +/** + * 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.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 * @@ -57,10 +100,23 @@ const CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/; /** - * 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'); +}