/// !----------------------------------------------------------------------------------------------------------- /// | // | `digest-fetch` is a wrapper of `node-fetch` or `fetch` to provide http digest authentication boostraping. // | /// !----------------------------------------------------------------------------------------------------------- const canRequire = typeof(require) == 'function' if (typeof(fetch) !== 'function' && canRequire) var fetch = require('node-fetch') const md5 = require('md5') const base64 = require('base-64') const supported_algorithms = ['MD5', 'MD5-sess'] const parse = (raw, field, trim=true) => { const regex = new RegExp(`${field}=("[^"]*"|[^,]*)`, "i") const match = regex.exec(raw) if (match) return trim ? match[1].replace(/[\s"]/g, '') : match[1] return null } class DigestClient { constructor(user, password, options={}) { this.user = user this.password = password this.nonceRaw = 'abcdef0123456789' this.logger = options.logger this.precomputedHash = options.precomputedHash let algorithm = options.algorithm || 'MD5' if (!supported_algorithms.includes(algorithm)) { if (this.logger) this.logger.warn(`Unsupported algorithm ${algorithm}, will try with MD5`) algorithm = 'MD5' } this.digest = { nc: 0, algorithm, realm: '' } this.hasAuth = false const _cnonceSize = parseInt(options.cnonceSize) this.cnonceSize = isNaN(_cnonceSize) ? 32 : _cnonceSize // cnonce length 32 as default // Custom authentication failure code for avoiding browser prompt: // https://stackoverflow.com/questions/9859627/how-to-prevent-browser-to-invoke-basic-auth-popup-and-handle-401-error-using-jqu this.statusCode = options.statusCode this.basic = options.basic || false } async fetch (url, options={}) { if (this.basic) return fetch(url, this.addBasicAuth(options)) const resp = await fetch(url, this.addAuth(url, options)) if (resp.status == 401 || (resp.status == this.statusCode && this.statusCode)) { this.hasAuth = false await this.parseAuth(resp.headers.get('www-authenticate')) if (this.hasAuth) { const respFinal = await fetch(url, this.addAuth(url, options)) if (respFinal.status == 401 || respFinal.status == this.statusCode) { this.hasAuth = false } else { this.digest.nc++ } return respFinal } } else this.digest.nc++ return resp } addBasicAuth (options={}) { let _options = {} if (typeof(options.factory) == 'function') { _options = options.factory() } else { _options = options } const auth = 'Basic ' + base64.encode(this.user + ":" + this.password) _options.headers = _options.headers || {} _options.headers.Authorization = auth; if (typeof(_options.headers.set) == 'function') { _options.headers.set('Authorization', auth) } if (this.logger) this.logger.debug(options) return _options } static computeHash(user, realm, password) { return md5(`${user}:${realm}:${password}`); } addAuth (url, options) { if (typeof(options.factory) == 'function') options = options.factory() if (!this.hasAuth) return options if (this.logger) this.logger.info(`requesting with auth carried`) const isRequest = typeof(url) === 'object' && typeof(url.url) === 'string' const urlStr = isRequest ? url.url : url const _url = urlStr.replace('//', '') const uri = _url.indexOf('/') == -1 ? '/' : _url.slice(_url.indexOf('/')) const method = options.method ? options.method.toUpperCase() : 'GET' let ha1 = this.precomputedHash ? this.password : DigestClient.computeHash(this.user, this.digest.realm, this.password) if (this.digest.algorithm === 'MD5-sess') { ha1 = md5(`${ha1}:${this.digest.nonce}:${this.digest.cnonce}`); } // optional MD5(entityBody) for 'auth-int' let _ha2 = '' if (this.digest.qop === 'auth-int') { // not implemented for auth-int if (this.logger) this.logger.warn('Sorry, auth-int is not implemented in this plugin') // const entityBody = xxx // _ha2 = ':' + md5(entityBody) } const ha2 = md5(`${method}:${uri}${_ha2}`); const ncString = ('00000000'+this.digest.nc).slice(-8) let _response = `${ha1}:${this.digest.nonce}:${ncString}:${this.digest.cnonce}:${this.digest.qop}:${ha2}` if (!this.digest.qop) _response = `${ha1}:${this.digest.nonce}:${ha2}` const response = md5(_response); const opaqueString = this.digest.opaque !== null ? `opaque="${this.digest.opaque}",` : '' const qopString = this.digest.qop ? `qop="${this.digest.qop}",` : '' const digest = `${this.digest.scheme} username="${this.user}",realm="${this.digest.realm}",\ nonce="${this.digest.nonce}",uri="${uri}",${opaqueString}${qopString}\ algorithm="${this.digest.algorithm}",response="${response}",nc=${ncString},cnonce="${this.digest.cnonce}"` options.headers = options.headers || {} options.headers.Authorization = digest if (typeof(options.headers.set) == 'function') { options.headers.set('Authorization', digest) } if (this.logger) this.logger.debug(options) // const {factory, ..._options} = options const _options = {} Object.assign(_options, options) delete _options.factory return _options; } async parseAuth (h) { this.lastAuth = h if (!h || h.length < 5) { this.hasAuth = false return } this.hasAuth = true this.digest.scheme = h.split(/\s/)[0] this.digest.realm = (parse(h, 'realm', false) || '').replace(/["]/g, '') this.digest.qop = this.parseQop(h) this.digest.opaque = parse(h, 'opaque') this.digest.nonce = parse(h, 'nonce') || '' this.digest.cnonce = this.makeNonce() this.digest.nc++ } parseQop (rawAuth) { // Following https://en.wikipedia.org/wiki/Digest_access_authentication // to parse valid qop // Samples // : qop="auth,auth-init",realm= // : qop=auth,realm= const _qop = parse(rawAuth, 'qop') if (_qop !== null) { const qops = _qop.split(',') if (qops.includes('auth')) return 'auth' else if (qops.includes('auth-int')) return 'auth-int' } // when not specified return null } makeNonce () { let uid = '' for (let i = 0; i < this.cnonceSize; ++i) { uid += this.nonceRaw[Math.floor(Math.random() * this.nonceRaw.length)]; } return uid } static parse(...args) { return parse(...args) } } if (typeof(window) === "object") window.DigestFetch = DigestClient module.exports = DigestClient