Skip to content

Commit 0a56a43

Browse files
committed
Remove www-authenticate dependency
- Added custom digest-auth.js module supporting RFC 7616 and RFC 2069 - Removed www-authenticate package dependency - Updated requester.js to use digest-auth.js - Added unit tests that should cover all MarkLogic scenarios
1 parent 9ba3c4d commit 0a56a43

File tree

5 files changed

+290
-19
lines changed

5 files changed

+290
-19
lines changed

lib/digest-auth.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use strict';
2+
3+
// Zero-dependency HTTP Digest Access Authentication helper.
4+
// Covers RFC 7616 MD5 and MD5-sess algorithms with qop="auth" (default).
5+
// Falls back to the legacy RFC 2069 variant if the server omits qop.
6+
//
7+
// Usage:
8+
// const createDigestAuth = require('./digest-auth');
9+
// const auth = createDigestAuth(user, pass, challengeHeader);
10+
// request.setHeader('Authorization', auth.authorize(method, path, bodyOptional));
11+
12+
const crypto = require('crypto');
13+
14+
/**
15+
* Creates a hex-encoded MD5 hash of the supplied string.
16+
* @param {string} data
17+
* @returns {string}
18+
*/
19+
function md5(data) {
20+
return crypto.createHash('md5').update(data).digest('hex');
21+
}
22+
23+
/**
24+
* Trim surrounding quotes from a header value.
25+
* @param {string} value
26+
* @returns {string}
27+
*/
28+
function unquote(value) {
29+
if (value === null || value === undefined) return '';
30+
const first = value[0];
31+
const last = value[value.length - 1];
32+
if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) {
33+
return value.slice(1, -1);
34+
}
35+
return value;
36+
}
37+
38+
/**
39+
* Parse a WWW-Authenticate challenge header into an object.
40+
* Handles quoted values and embedded commas in quotes.
41+
* @param {string} header
42+
* @returns {Object.<string,string>}
43+
*/
44+
function parseChallenge(header) {
45+
// Remove scheme (e.g., "Digest ") prefix if present
46+
header = header.replace(/^\s*Digest\s+/i, '');
47+
const params = {};
48+
// Use global regex to capture key=value pairs robustly
49+
const pairRE = /(\w+)=\s*(?:"([^"]*)"|([\w.-]+))/g;
50+
let match;
51+
while ((match = pairRE.exec(header)) !== null) {
52+
const key = match[1];
53+
const val = match[2] !== undefined ? match[2] : match[3];
54+
params[key] = val;
55+
}
56+
return params;
57+
}
58+
59+
/**
60+
* Creates a Digest authenticator compatible with the interface expected by requester.js
61+
* @param {string} user
62+
* @param {string} password
63+
* @param {string} challengeHeader – The raw WWW-Authenticate header value
64+
* @returns {{authorize:(method:string, uri:string, entityBody?:string)=>string}}
65+
*/
66+
function createDigestAuth(user, password, challengeHeader) {
67+
const challenge = parseChallenge(challengeHeader);
68+
const {
69+
realm = '',
70+
nonce = '',
71+
qop: qopRaw,
72+
algorithm = 'MD5',
73+
opaque
74+
} = challenge;
75+
76+
// qop may contain a list: "auth,auth-int"
77+
const qopList = (qopRaw || '').split(/,\s*/);
78+
const qop = qopList.includes('auth') ? 'auth' : (qopList.includes('auth-int') ? 'auth-int' : undefined);
79+
80+
// Pre-calculate HA1 for MD5; MD5-sess handled per request as it needs cnonce.
81+
const ha1Base = `${user}:${realm}:${password}`;
82+
const ha1Static = md5(ha1Base);
83+
84+
let nonceCount = 0;
85+
86+
function authorize(method, uri, entityBody = '') {
87+
nonceCount += 1;
88+
const nc = nonceCount.toString(16).padStart(8, '0');
89+
const cnonce = crypto.randomBytes(8).toString('hex');
90+
91+
let ha1 = ha1Static;
92+
if (/md5-sess/i.test(algorithm)) {
93+
ha1 = md5(`${ha1Static}:${nonce}:${cnonce}`);
94+
}
95+
96+
let ha2;
97+
if (qop === 'auth-int') {
98+
const bodyHash = md5(entityBody);
99+
ha2 = md5(`${method}:${uri}:${bodyHash}`);
100+
} else {
101+
ha2 = md5(`${method}:${uri}`);
102+
}
103+
104+
let response;
105+
if (qop) {
106+
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
107+
} else {
108+
// RFC 2069 – no qop, nc, cnonce
109+
response = md5(`${ha1}:${nonce}:${ha2}`);
110+
}
111+
112+
const parts = [
113+
`username="${user}"`,
114+
`realm="${realm}"`,
115+
`nonce="${nonce}"`,
116+
`uri="${uri}"`,
117+
];
118+
if (opaque) {
119+
parts.push(`opaque="${opaque}"`);
120+
}
121+
if (qop) {
122+
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
123+
}
124+
parts.push(`response="${response}"`);
125+
if (algorithm) {
126+
parts.push(`algorithm=${algorithm}`);
127+
}
128+
129+
return `Digest ${parts.join(', ')}`;
130+
}
131+
132+
return { authorize };
133+
}
134+
135+
module.exports = createDigestAuth;

lib/requester.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Copyright © 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
33
*/
44
'use strict';
5-
var createAuthInitializer = require('www-authenticate');
5+
var createDigestAuth = require('./digest-auth.js');
66
var Kerberos = require('./optional.js')
77
.libraryProperty('kerberos', 'Kerberos');
88
var Multipart = require('multipart-stream');
@@ -14,7 +14,7 @@ const https = require('https');
1414
const formData = require('form-data');
1515

1616
function createAuthenticator(client, user, password, challenge) {
17-
var authenticator = createAuthInitializer.call(null, user, password)(challenge);
17+
var authenticator = createDigestAuth(user, password, challenge);
1818
if (!client.authenticator) {
1919
client.authenticator = {};
2020
}

package-lock.json

Lines changed: 1 addition & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@
3838
"json-text-sequence": "^1.0.1",
3939
"multipart-stream": "^2.0.1",
4040
"qs": "^6.11.0",
41-
"through2": "^4.0.2",
42-
"www-authenticate": "^0.6.3"
41+
"through2": "^4.0.2"
4342
},
4443
"repository": {
4544
"type": "git",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
'use strict';
2+
// Unit test for zero-dep digest-auth implementation
3+
const should = require('should');
4+
const createDigestAuth = require('../lib/digest-auth');
5+
6+
describe('digest-auth utility', function () {
7+
it('generates a valid Digest Authorization header (MD5, qop=auth)', function () {
8+
const user = 'user';
9+
const pass = 'password';
10+
const challenge = 'Digest realm="[email protected]", qop="auth", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
11+
12+
const auth = createDigestAuth(user, pass, challenge);
13+
const header = auth.authorize('GET', '/dir/index.html');
14+
15+
header.should.startWith('Digest ');
16+
header.should.match(/username="user"/);
17+
header.should.match(/realm="testrealm@host.com"/);
18+
header.should.match(/uri="\/dir\/index.html"/);
19+
header.should.match(/response="[a-f0-9]{32}"/);
20+
});
21+
22+
it('handles algorithm="MD5-sess"', function () {
23+
const challenge = 'Digest realm="edge", algorithm="MD5-sess", qop="auth", nonce="abc123"';
24+
const auth = createDigestAuth('alice', 'secret', challenge);
25+
const header = auth.authorize('GET', '/path');
26+
27+
header.should.match(/algorithm=MD5-sess/);
28+
header.should.match(/response="[a-f0-9]{32}"/);
29+
});
30+
31+
it('handles qop="auth-int" and body hash', function () {
32+
const body = 'HELLO';
33+
const challenge = 'Digest realm="edge", qop="auth-int", nonce="def456"';
34+
const auth = createDigestAuth('bob', 'password', challenge);
35+
const header = auth.authorize('POST', '/submit', body);
36+
37+
header.should.match(/qop=auth-int/);
38+
header.should.match(/nc=00000001/);
39+
header.should.match(/response="[a-f0-9]{32}"/);
40+
});
41+
42+
it('increments nonce-count (nc) on successive calls', function () {
43+
const challenge = 'Digest realm="edge", qop="auth", nonce="ghi789"';
44+
const auth = createDigestAuth('carol', 'hunter2', challenge);
45+
const h1 = auth.authorize('GET', '/a');
46+
const h2 = auth.authorize('GET', '/b');
47+
48+
h1.should.match(/nc=00000001/);
49+
h2.should.match(/nc=00000002/);
50+
});
51+
52+
// RFC 2069 legacy support (no qop)
53+
it('handles RFC 2069 challenges without qop parameter', function () {
54+
const challenge = 'Digest realm="legacy", nonce="xyz789"';
55+
const auth = createDigestAuth('olduser', 'oldpass', challenge);
56+
const header = auth.authorize('GET', '/legacy');
57+
58+
header.should.startWith('Digest ');
59+
header.should.match(/username="olduser"/);
60+
header.should.match(/realm="legacy"/);
61+
header.should.match(/nonce="xyz789"/);
62+
header.should.match(/response="[a-f0-9]{32}"/);
63+
// Should NOT contain qop, nc, or cnonce for RFC 2069
64+
header.should.not.match(/qop=/);
65+
header.should.not.match(/nc=/);
66+
header.should.not.match(/cnonce=/);
67+
});
68+
69+
// Multiple qop values
70+
it('handles multiple qop values and selects auth over auth-int', function () {
71+
const challenge = 'Digest realm="multi", qop="auth-int,auth", nonce="multi123"';
72+
const auth = createDigestAuth('multiuser', 'multipass', challenge);
73+
const header = auth.authorize('POST', '/multi', 'body');
74+
75+
header.should.match(/qop=auth/);
76+
header.should.not.match(/qop=auth-int/);
77+
});
78+
79+
it('prefers auth-int when auth is not available', function () {
80+
const challenge = 'Digest realm="authint", qop="auth-int", nonce="authint123"';
81+
const auth = createDigestAuth('intuser', 'intpass', challenge);
82+
const header = auth.authorize('POST', '/authint', 'testbody');
83+
84+
header.should.match(/qop=auth-int/);
85+
});
86+
87+
// Edge cases and error handling
88+
it('handles challenges with opaque parameter', function () {
89+
const challenge = 'Digest realm="opaque-test", qop="auth", nonce="opaque123", opaque="abc123def456"';
90+
const auth = createDigestAuth('opaqueuser', 'opaquepass', challenge);
91+
const header = auth.authorize('GET', '/opaque');
92+
93+
header.should.match(/opaque="abc123def456"/);
94+
});
95+
96+
it('handles challenges with algorithm parameter explicitly set to MD5', function () {
97+
const challenge = 'Digest realm="explicit", algorithm="MD5", qop="auth", nonce="explicit123"';
98+
const auth = createDigestAuth('explicituser', 'explicitpass', challenge);
99+
const header = auth.authorize('GET', '/explicit');
100+
101+
header.should.match(/algorithm=MD5/);
102+
});
103+
104+
it('handles empty or missing realm gracefully', function () {
105+
const challenge = 'Digest qop="auth", nonce="norealm123"';
106+
const auth = createDigestAuth('noreamluser', 'norealmpass', challenge);
107+
const header = auth.authorize('GET', '/norealm');
108+
109+
header.should.match(/realm=""/);
110+
header.should.match(/response="[a-f0-9]{32}"/);
111+
});
112+
113+
it('handles quoted values with embedded commas', function () {
114+
const challenge = 'Digest realm="test,realm", qop="auth", nonce="comma,test"';
115+
const auth = createDigestAuth('commauser', 'commapass', challenge);
116+
const header = auth.authorize('GET', '/comma');
117+
118+
header.should.match(/realm="test,realm"/);
119+
header.should.match(/nonce="comma,test"/);
120+
});
121+
122+
// MarkLogic-specific scenarios
123+
it('works with typical MarkLogic server challenge', function () {
124+
// Simulate a typical MarkLogic digest challenge
125+
const challenge = 'Digest realm="public", qop="auth", nonce="1234567890abcdef", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
126+
const auth = createDigestAuth('mluser', 'mlpassword', challenge);
127+
const header = auth.authorize('GET', '/v1/documents');
128+
129+
header.should.startWith('Digest ');
130+
header.should.match(/username="mluser"/);
131+
header.should.match(/realm="public"/);
132+
header.should.match(/uri="\/v1\/documents"/);
133+
header.should.match(/qop=auth/);
134+
header.should.match(/nc=00000001/);
135+
header.should.match(/cnonce="[a-f0-9]{16}"/);
136+
header.should.match(/response="[a-f0-9]{32}"/);
137+
header.should.match(/opaque="5ccc069c403ebaf9f0171e9517f40e41"/);
138+
});
139+
140+
it('handles POST requests with body content for MarkLogic document insertion', function () {
141+
const challenge = 'Digest realm="public", qop="auth-int", nonce="mlnonce123"';
142+
const auth = createDigestAuth('mluser', 'mlpass', challenge);
143+
const jsonBody = '{"test": "document"}';
144+
const header = auth.authorize('POST', '/v1/documents', jsonBody);
145+
146+
header.should.match(/qop=auth-int/);
147+
header.should.match(/uri="\/v1\/documents"/);
148+
header.should.match(/response="[a-f0-9]{32}"/);
149+
});
150+
});
151+

0 commit comments

Comments
 (0)