diff --git a/lib/digest-auth.js b/lib/digest-auth.js new file mode 100644 index 00000000..90e410bc --- /dev/null +++ b/lib/digest-auth.js @@ -0,0 +1,135 @@ +'use strict'; + +// Zero-dependency HTTP Digest Access Authentication helper. +// Covers RFC 7616 MD5 and MD5-sess algorithms with qop="auth" (default). +// Falls back to the legacy RFC 2069 variant if the server omits qop. +// +// Usage: +// const createDigestAuth = require('./digest-auth'); +// const auth = createDigestAuth(user, pass, challengeHeader); +// request.setHeader('Authorization', auth.authorize(method, path, bodyOptional)); + +const crypto = require('crypto'); + +/** +* Creates a hex-encoded MD5 hash of the supplied string. +* @param {string} data +* @returns {string} +*/ +function md5(data) { + return crypto.createHash('md5').update(data).digest('hex'); +} + +/** +* Trim surrounding quotes from a header value. +* @param {string} value +* @returns {string} +*/ +function unquote(value) { + if (value === null || value === undefined) return ''; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { + return value.slice(1, -1); + } + return value; +} + +/** +* Parse a WWW-Authenticate challenge header into an object. +* Handles quoted values and embedded commas in quotes. +* @param {string} header +* @returns {Object.} +*/ +function parseChallenge(header) { + // Remove scheme (e.g., "Digest ") prefix if present + header = header.replace(/^\s*Digest\s+/i, ''); + const params = {}; + // Use global regex to capture key=value pairs robustly + const pairRE = /(\w+)=\s*(?:"([^"]*)"|([\w.-]+))/g; + let match; + while ((match = pairRE.exec(header)) !== null) { + const key = match[1]; + const val = match[2] !== undefined ? match[2] : match[3]; + params[key] = val; + } + return params; +} + +/** +* Creates a Digest authenticator compatible with the interface expected by requester.js +* @param {string} user +* @param {string} password +* @param {string} challengeHeader – The raw WWW-Authenticate header value +* @returns {{authorize:(method:string, uri:string, entityBody?:string)=>string}} +*/ +function createDigestAuth(user, password, challengeHeader) { + const challenge = parseChallenge(challengeHeader); + const { + realm = '', + nonce = '', + qop: qopRaw, + algorithm = 'MD5', + opaque + } = challenge; + + // qop may contain a list: "auth,auth-int" + const qopList = (qopRaw || '').split(/,\s*/); + const qop = qopList.includes('auth') ? 'auth' : (qopList.includes('auth-int') ? 'auth-int' : undefined); + + // Pre-calculate HA1 for MD5; MD5-sess handled per request as it needs cnonce. + const ha1Base = `${user}:${realm}:${password}`; + const ha1Static = md5(ha1Base); + + let nonceCount = 0; + + function authorize(method, uri, entityBody = '') { + nonceCount += 1; + const nc = nonceCount.toString(16).padStart(8, '0'); + const cnonce = crypto.randomBytes(8).toString('hex'); + + let ha1 = ha1Static; + if (/md5-sess/i.test(algorithm)) { + ha1 = md5(`${ha1Static}:${nonce}:${cnonce}`); + } + + let ha2; + if (qop === 'auth-int') { + const bodyHash = md5(entityBody); + ha2 = md5(`${method}:${uri}:${bodyHash}`); + } else { + ha2 = md5(`${method}:${uri}`); + } + + let response; + if (qop) { + response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`); + } else { + // RFC 2069 – no qop, nc, cnonce + response = md5(`${ha1}:${nonce}:${ha2}`); + } + + const parts = [ + `username="${user}"`, + `realm="${realm}"`, + `nonce="${nonce}"`, + `uri="${uri}"`, + ]; + if (opaque) { + parts.push(`opaque="${opaque}"`); + } + if (qop) { + parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`); + } + parts.push(`response="${response}"`); + if (algorithm) { + parts.push(`algorithm=${algorithm}`); + } + + return `Digest ${parts.join(', ')}`; + } + + return { authorize }; +} + +module.exports = createDigestAuth; diff --git a/lib/requester.js b/lib/requester.js index 1a5668a0..8e0e8b85 100644 --- a/lib/requester.js +++ b/lib/requester.js @@ -2,7 +2,7 @@ * Copyright © 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ 'use strict'; -var createAuthInitializer = require('www-authenticate'); +var createDigestAuth = require('./digest-auth.js'); var Kerberos = require('./optional.js') .libraryProperty('kerberos', 'Kerberos'); var Multipart = require('multipart-stream'); @@ -14,7 +14,7 @@ const https = require('https'); const formData = require('form-data'); function createAuthenticator(client, user, password, challenge) { - var authenticator = createAuthInitializer.call(null, user, password)(challenge); + var authenticator = createDigestAuth(user, password, challenge); if (!client.authenticator) { client.authenticator = {}; } diff --git a/package-lock.json b/package-lock.json index bfa4c179..d15957dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,7 @@ "json-text-sequence": "^1.0.1", "multipart-stream": "^2.0.1", "qs": "^6.11.0", - "through2": "^4.0.2", - "www-authenticate": "^0.6.3" + "through2": "^4.0.2" }, "devDependencies": { "@jsdoc/salty": "0.2.3", @@ -5781,14 +5780,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/www-authenticate": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/www-authenticate/-/www-authenticate-0.6.3.tgz", - "integrity": "sha512-8VkdLBJiBh5aXlJvcVaPykwSI//OA+Sxw7g84vIyCqoqlXtLupGNhyXxbgVuZ7g5ZS+lCJ4bTtcw/gJciqEuAg==", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -10151,11 +10142,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "www-authenticate": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/www-authenticate/-/www-authenticate-0.6.3.tgz", - "integrity": "sha512-8VkdLBJiBh5aXlJvcVaPykwSI//OA+Sxw7g84vIyCqoqlXtLupGNhyXxbgVuZ7g5ZS+lCJ4bTtcw/gJciqEuAg==" - }, "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index 497ab934..170c82e9 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,7 @@ "json-text-sequence": "^1.0.1", "multipart-stream": "^2.0.1", "qs": "^6.11.0", - "through2": "^4.0.2", - "www-authenticate": "^0.6.3" + "through2": "^4.0.2" }, "repository": { "type": "git", diff --git a/test-basic/digest-auth-unit-test.js b/test-basic/digest-auth-unit-test.js new file mode 100644 index 00000000..dafc6e34 --- /dev/null +++ b/test-basic/digest-auth-unit-test.js @@ -0,0 +1,151 @@ +'use strict'; +// Unit test for zero-dep digest-auth implementation +const should = require('should'); +const createDigestAuth = require('../lib/digest-auth'); + +describe('digest-auth utility', function () { + it('generates a valid Digest Authorization header (MD5, qop=auth)', function () { + const user = 'user'; + const pass = 'password'; + const challenge = 'Digest realm="testrealm@host.com", qop="auth", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"'; + + const auth = createDigestAuth(user, pass, challenge); + const header = auth.authorize('GET', '/dir/index.html'); + + header.should.startWith('Digest '); + header.should.match(/username="user"/); + header.should.match(/realm="testrealm@host.com"/); + header.should.match(/uri="\/dir\/index.html"/); + header.should.match(/response="[a-f0-9]{32}"/); + }); + + it('handles algorithm="MD5-sess"', function () { + const challenge = 'Digest realm="edge", algorithm="MD5-sess", qop="auth", nonce="abc123"'; + const auth = createDigestAuth('alice', 'secret', challenge); + const header = auth.authorize('GET', '/path'); + + header.should.match(/algorithm=MD5-sess/); + header.should.match(/response="[a-f0-9]{32}"/); + }); + + it('handles qop="auth-int" and body hash', function () { + const body = 'HELLO'; + const challenge = 'Digest realm="edge", qop="auth-int", nonce="def456"'; + const auth = createDigestAuth('bob', 'password', challenge); + const header = auth.authorize('POST', '/submit', body); + + header.should.match(/qop=auth-int/); + header.should.match(/nc=00000001/); + header.should.match(/response="[a-f0-9]{32}"/); + }); + + it('increments nonce-count (nc) on successive calls', function () { + const challenge = 'Digest realm="edge", qop="auth", nonce="ghi789"'; + const auth = createDigestAuth('carol', 'hunter2', challenge); + const h1 = auth.authorize('GET', '/a'); + const h2 = auth.authorize('GET', '/b'); + + h1.should.match(/nc=00000001/); + h2.should.match(/nc=00000002/); + }); + + // RFC 2069 legacy support (no qop) + it('handles RFC 2069 challenges without qop parameter', function () { + const challenge = 'Digest realm="legacy", nonce="xyz789"'; + const auth = createDigestAuth('olduser', 'oldpass', challenge); + const header = auth.authorize('GET', '/legacy'); + + header.should.startWith('Digest '); + header.should.match(/username="olduser"/); + header.should.match(/realm="legacy"/); + header.should.match(/nonce="xyz789"/); + header.should.match(/response="[a-f0-9]{32}"/); + // Should NOT contain qop, nc, or cnonce for RFC 2069 + header.should.not.match(/qop=/); + header.should.not.match(/nc=/); + header.should.not.match(/cnonce=/); + }); + + // Multiple qop values + it('handles multiple qop values and selects auth over auth-int', function () { + const challenge = 'Digest realm="multi", qop="auth-int,auth", nonce="multi123"'; + const auth = createDigestAuth('multiuser', 'multipass', challenge); + const header = auth.authorize('POST', '/multi', 'body'); + + header.should.match(/qop=auth/); + header.should.not.match(/qop=auth-int/); + }); + + it('prefers auth-int when auth is not available', function () { + const challenge = 'Digest realm="authint", qop="auth-int", nonce="authint123"'; + const auth = createDigestAuth('intuser', 'intpass', challenge); + const header = auth.authorize('POST', '/authint', 'testbody'); + + header.should.match(/qop=auth-int/); + }); + + // Edge cases and error handling + it('handles challenges with opaque parameter', function () { + const challenge = 'Digest realm="opaque-test", qop="auth", nonce="opaque123", opaque="abc123def456"'; + const auth = createDigestAuth('opaqueuser', 'opaquepass', challenge); + const header = auth.authorize('GET', '/opaque'); + + header.should.match(/opaque="abc123def456"/); + }); + + it('handles challenges with algorithm parameter explicitly set to MD5', function () { + const challenge = 'Digest realm="explicit", algorithm="MD5", qop="auth", nonce="explicit123"'; + const auth = createDigestAuth('explicituser', 'explicitpass', challenge); + const header = auth.authorize('GET', '/explicit'); + + header.should.match(/algorithm=MD5/); + }); + + it('handles empty or missing realm gracefully', function () { + const challenge = 'Digest qop="auth", nonce="norealm123"'; + const auth = createDigestAuth('noreamluser', 'norealmpass', challenge); + const header = auth.authorize('GET', '/norealm'); + + header.should.match(/realm=""/); + header.should.match(/response="[a-f0-9]{32}"/); + }); + + it('handles quoted values with embedded commas', function () { + const challenge = 'Digest realm="test,realm", qop="auth", nonce="comma,test"'; + const auth = createDigestAuth('commauser', 'commapass', challenge); + const header = auth.authorize('GET', '/comma'); + + header.should.match(/realm="test,realm"/); + header.should.match(/nonce="comma,test"/); + }); + + // MarkLogic-specific scenarios + it('works with typical MarkLogic server challenge', function () { + // Simulate a typical MarkLogic digest challenge + const challenge = 'Digest realm="public", qop="auth", nonce="1234567890abcdef", opaque="5ccc069c403ebaf9f0171e9517f40e41"'; + const auth = createDigestAuth('mluser', 'mlpassword', challenge); + const header = auth.authorize('GET', '/v1/documents'); + + header.should.startWith('Digest '); + header.should.match(/username="mluser"/); + header.should.match(/realm="public"/); + header.should.match(/uri="\/v1\/documents"/); + header.should.match(/qop=auth/); + header.should.match(/nc=00000001/); + header.should.match(/cnonce="[a-f0-9]{16}"/); + header.should.match(/response="[a-f0-9]{32}"/); + header.should.match(/opaque="5ccc069c403ebaf9f0171e9517f40e41"/); + }); + + it('handles POST requests with body content for MarkLogic document insertion', function () { + const challenge = 'Digest realm="public", qop="auth-int", nonce="mlnonce123"'; + const auth = createDigestAuth('mluser', 'mlpass', challenge); + const jsonBody = '{"test": "document"}'; + const header = auth.authorize('POST', '/v1/documents', jsonBody); + + header.should.match(/qop=auth-int/); + header.should.match(/uri="\/v1\/documents"/); + header.should.match(/response="[a-f0-9]{32}"/); + }); +}); +