Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge pull request #338 from nylen/digest-auth

Add more auth options, including digest support
  • Loading branch information...
commit 8d35203e4134728f9c6875b8d9d83924af2d5bc5 2 parents e51cc63 + 85fd359
@mikeal mikeal authored
View
21 README.md
@@ -115,6 +115,26 @@ form.append('my_file', fs.createReadStream(path.join(__dirname, 'doodle.png'))
form.append('remote_file', request('http://google.com/doodle.png'))
```
+## HTTP Authentication
+
+```javascript
+request.auth('username', 'password', false).get('http://some.server.com/');
+// or
+request.get('http://some.server.com/', {
+ 'auth': {
+ 'user': 'username',
+ 'pass': 'password',
+ 'sendImmediately': false
+ }
+});
+```
+
+If passed as an option, `auth` should be a hash containing values `user` || `username`, `password` || `pass`, and `sendImmediately` (optional). The method form takes parameters `auth(username, password, sendImmediately)`.
+
+`sendImmediately` defaults to true, which will cause a basic authentication header to be sent. If `sendImmediately` is `false`, then `request` will retry with a proper authentication header after receiving a 401 response from the server (which must contain a `WWW-Authenticate` header indicating the required authentication method).
+
+Digest authentication is supported, but it only works with `sendImmediately` set to `false` (otherwise `request` will send basic authentication on the initial request, which will probably cause the request to fail).
+
## OAuth Signing
```javascript
@@ -173,6 +193,7 @@ The first argument can be either a url or an options object. The only required o
* `headers` - http headers, defaults to {}
* `body` - entity body for POST and PUT requests. Must be buffer or string.
* `form` - when passed an object this will set `body` but to a querystring representation of value and adds `Content-type: application/x-www-form-urlencoded; charset=utf-8` header. When passed no option a FormData instance is returned that will be piped to request.
+* `auth` - A hash containing values `user` || `username`, `password` || `pass`, and `sendImmediately` (optional). See documentation above.
* `json` - sets `body` but to JSON representation of value and adds `Content-type: application/json` header. Additionally, parses the response body as json.
* `multipart` - (experimental) array of objects which contains their own headers and `body` attribute. Sends `multipart/related` request. See example below.
* `followRedirect` - follow HTTP 3xx responses as redirects. defaults to true.
View
126 main.js
@@ -19,6 +19,7 @@ var http = require('http')
, util = require('util')
, stream = require('stream')
, qs = require('querystring')
+ , crypto = require('crypto')
, oauth = require('./oauth')
, uuid = require('./uuid')
, ForeverAgent = require('./forever')
@@ -48,6 +49,10 @@ function toBase64 (str) {
return (new Buffer(str || "", "ascii")).toString("base64")
}
+function md5 (str) {
+ return crypto.createHash('md5').update(str).digest('hex')
+}
+
// Hacky fix for pre-0.4.4 https
if (https && !https.Agent) {
https.Agent = function (options) {
@@ -262,8 +267,16 @@ Request.prototype.init = function (options) {
self.aws(options.aws)
}
+ if (options.auth) {
+ self.auth(
+ options.auth.user || options.auth.username,
+ options.auth.pass || options.auth.password,
+ options.auth.sendImmediately)
+ }
+
if (self.uri.auth && !self.headers.authorization) {
- self.headers.authorization = "Basic " + toBase64(self.uri.auth.split(':').map(function(item){ return qs.unescape(item)}).join(':'))
+ var authPieces = self.uri.auth.split(':').map(function(item){ return qs.unescape(item) })
+ self.auth(authPieces[0], authPieces[1], true)
}
if (self.proxy && self.proxy.auth && !self.headers['proxy-authorization'] && !self.tunnel) {
self.headers['proxy-authorization'] = "Basic " + toBase64(self.proxy.auth.split(':').map(function(item){ return qs.unescape(item)}).join(':'))
@@ -520,7 +533,13 @@ Request.prototype.start = function () {
if (self._aws) {
self.aws(self._aws, true)
}
- self.req = self.httpModule.request(self, function (response) {
+
+ // We have a method named auth, which is completely different from the http.request
+ // auth option. If we don't remove it, we're gonna have a bad time.
+ var reqOptions = copy(self)
+ delete reqOptions.auth
+
+ self.req = self.httpModule.request(reqOptions, function (response) {
if (response.connection.listeners('error').indexOf(self._parserErrorHandler) === -1) {
response.connection.once('error', self._parserErrorHandler)
}
@@ -555,22 +574,86 @@ Request.prototype.start = function () {
else addCookie(response.headers['set-cookie'])
}
- if (response.statusCode >= 300 && response.statusCode < 400 &&
- (self.followAllRedirects ||
- (self.followRedirect && (self.method !== 'PUT' && self.method !== 'POST' && self.method !== 'DELETE'))) &&
- response.headers.location) {
+ var redirectTo = null
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
+ if (self.followAllRedirects) {
+ redirectTo = response.headers.location
+ } else if (self.followRedirect) {
+ switch (self.method) {
+ case 'PUT':
+ case 'POST':
+ case 'DELETE':
+ // Do not follow redirects
+ break
+ default:
+ redirectTo = response.headers.location
+ break
+ }
+ }
+ } else if (response.statusCode == 401 && self._hasAuth && !self._sentAuth) {
+ var authHeader = response.headers['www-authenticate']
+ var authVerb = authHeader && authHeader.split(' ')[0]
+ switch (authVerb) {
+ case 'Basic':
+ self.auth(self._user, self._pass, true)
+ redirectTo = self.uri
+ break
+
+ case 'Digest':
+ // TODO: More complete implementation of RFC 2617. For reference:
+ // http://tools.ietf.org/html/rfc2617#section-3
+ // https://github.com/bagder/curl/blob/master/lib/http_digest.c
+
+ var matches = authHeader.match(/([a-z0-9_-]+)="([^"]+)"/gi)
+ var challenge = {}
+
+ for (var i = 0; i < matches.length; i++) {
+ var eqPos = matches[i].indexOf('=')
+ var key = matches[i].substring(0, eqPos)
+ var quotedValue = matches[i].substring(eqPos + 1)
+ challenge[key] = quotedValue.substring(1, quotedValue.length - 1)
+ }
+
+ var ha1 = md5(self._user + ':' + challenge.realm + ':' + self._pass)
+ var ha2 = md5(self.method + ':' + self.uri.path)
+ var digestResponse = md5(ha1 + ':' + challenge.nonce + ':1::auth:' + ha2)
+ var authValues = {
+ username: self._user,
+ realm: challenge.realm,
+ nonce: challenge.nonce,
+ uri: self.uri.path,
+ qop: challenge.qop,
+ response: digestResponse,
+ nc: 1,
+ cnonce: ''
+ }
+
+ authHeader = []
+ for (var k in authValues) {
+ authHeader.push(k + '="' + authValues[k] + '"')
+ }
+ authHeader = 'Digest ' + authHeader.join(', ')
+ self.setHeader('authorization', authHeader)
+ self._sentAuth = true
+
+ redirectTo = self.uri
+ break
+ }
+ }
+
+ if (redirectTo) {
if (self._redirectsFollowed >= self.maxRedirects) {
self.emit('error', new Error("Exceeded maxRedirects. Probably stuck in a redirect loop "+self.uri.href))
return
}
self._redirectsFollowed += 1
- if (!isUrl.test(response.headers.location)) {
- response.headers.location = url.resolve(self.uri.href, response.headers.location)
+ if (!isUrl.test(redirectTo)) {
+ redirectTo = url.resolve(self.uri.href, redirectTo)
}
var uriPrev = self.uri
- self.uri = url.parse(response.headers.location)
+ self.uri = url.parse(redirectTo)
// handle the case where we change protocol from https to http or vice versa
if (self.uri.protocol !== uriPrev.protocol) {
@@ -579,23 +662,25 @@ Request.prototype.start = function () {
self.redirects.push(
{ statusCode : response.statusCode
- , redirectUri: response.headers.location
+ , redirectUri: redirectTo
}
)
- if (self.followAllRedirects) self.method = 'GET'
+ if (self.followAllRedirects && response.statusCode != 401) self.method = 'GET'
// self.method = 'GET' // Force all redirects to use GET || commented out fixes #215
delete self.src
delete self.req
delete self.agent
delete self._started
- delete self.body
- delete self._form
+ if (response.statusCode != 401) {
+ delete self.body
+ delete self._form
+ }
if (self.headers) {
delete self.headers.host
delete self.headers['content-type']
delete self.headers['content-length']
}
- if (log) log('Redirect to %uri', self)
+ if (log) log('Redirect to %uri due to status %status', {uri: self.uri, status: response.statusCode})
self.init()
return // Ignore the rest of the response
} else {
@@ -821,6 +906,19 @@ function getHeader(name, headers) {
})
return result
}
+Request.prototype.auth = function (user, pass, sendImmediately) {
+ if (typeof user !== 'string' || typeof pass !== 'string') {
+ throw new Error('auth() received invalid user or password')
+ }
+ this._user = user
+ this._pass = pass
+ this._hasAuth = true
+ if (sendImmediately || typeof sendImmediately == 'undefined') {
+ this.setHeader('authorization', 'Basic ' + toBase64(user + ':' + pass))
+ this._sentAuth = true
+ }
+ return this
+}
Request.prototype.aws = function (opts, now) {
if (!now) {
this._aws = opts
View
4 tests/run.js
@@ -4,10 +4,12 @@ var spawn = require('child_process').spawn
;
var tests = [
- 'test-body.js'
+ 'test-basic-auth.js'
+ , 'test-body.js'
, 'test-cookie.js'
, 'test-cookiejar.js'
, 'test-defaults.js'
+ , 'test-digest-auth.js'
, 'test-errors.js'
, 'test-form.js'
, 'test-follow-all-303.js'
View
66 tests/test-basic-auth.js
@@ -0,0 +1,66 @@
+var assert = require('assert')
+ , http = require('http')
+ , request = require('../main')
+ ;
+
+var numBasicRequests = 0;
+
+var basicServer = http.createServer(function (req, res) {
+ console.error('Basic auth server: ', req.method, req.url);
+ numBasicRequests++;
+
+ var ok;
+
+ if (req.headers.authorization) {
+ if (req.headers.authorization == 'Basic ' + new Buffer('test:testing2').toString('base64')) {
+ ok = true;
+ } else {
+ // Bad auth header, don't send back WWW-Authenticate header
+ ok = false;
+ }
+ } else {
+ // No auth header, send back WWW-Authenticate header
+ ok = false;
+ res.setHeader('www-authenticate', 'Basic realm="Private"');
+ }
+
+ if (ok) {
+ console.log('request ok');
+ res.end('ok');
+ } else {
+ console.log('status=401');
+ res.statusCode = 401;
+ res.end('401');
+ }
+});
+
+basicServer.listen(6767);
+
+request({
+ 'method': 'GET',
+ 'uri': 'http://localhost:6767/test/',
+ 'auth': {
+ 'user': 'test',
+ 'pass': 'testing2',
+ 'sendImmediately': false
+ }
+}, function(error, response, body) {
+ assert.equal(response.statusCode, 200);
+ assert.equal(numBasicRequests, 2);
+
+ // If we don't set sendImmediately = false, request will send basic auth
+ request({
+ 'method': 'GET',
+ 'uri': 'http://localhost:6767/test2/',
+ 'auth': {
+ 'user': 'test',
+ 'pass': 'testing2'
+ }
+ }, function(error, response, body) {
+ assert.equal(response.statusCode, 200);
+ assert.equal(numBasicRequests, 3);
+
+ console.log('All tests passed');
+ basicServer.close();
+ });
+});
View
69 tests/test-digest-auth.js
@@ -0,0 +1,69 @@
+var assert = require('assert')
+ , http = require('http')
+ , request = require('../main')
+ ;
+
+// Test digest auth
+// Using header values captured from interaction with Apache
+
+var numDigestRequests = 0;
+
+var digestServer = http.createServer(function (req, res) {
+ console.error('Digest auth server: ', req.method, req.url);
+ numDigestRequests++;
+
+ var ok;
+
+ if (req.headers.authorization) {
+ if (req.headers.authorization == 'Digest username="test", realm="Private", nonce="WpcHS2/TBAA=dffcc0dbd5f96d49a5477166649b7c0ae3866a93", uri="/test/", qop="auth", response="54753ce37c10cb20b09b769f0bed730e", nc="1", cnonce=""') {
+ ok = true;
+ } else {
+ // Bad auth header, don't send back WWW-Authenticate header
+ ok = false;
+ }
+ } else {
+ // No auth header, send back WWW-Authenticate header
+ ok = false;
+ res.setHeader('www-authenticate', 'Digest realm="Private", nonce="WpcHS2/TBAA=dffcc0dbd5f96d49a5477166649b7c0ae3866a93", algorithm=MD5, qop="auth"');
+ }
+
+ if (ok) {
+ console.log('request ok');
+ res.end('ok');
+ } else {
+ console.log('status=401');
+ res.statusCode = 401;
+ res.end('401');
+ }
+});
+
+digestServer.listen(6767);
+
+request({
+ 'method': 'GET',
+ 'uri': 'http://localhost:6767/test/',
+ 'auth': {
+ 'user': 'test',
+ 'pass': 'testing',
+ 'sendImmediately': false
+ }
+}, function(error, response, body) {
+ assert.equal(response.statusCode, 200);
+ assert.equal(numDigestRequests, 2);
+
+ // If we don't set sendImmediately = false, request will send basic auth
+ request({
+ 'method': 'GET',
+ 'uri': 'http://localhost:6767/test/',
+ 'auth': {
+ 'user': 'test',
+ 'pass': 'testing'
+ }
+ }, function(error, response, body) {
+ assert.equal(response.statusCode, 401);
+ assert.equal(numDigestRequests, 3);
+
+ console.log('All tests passed');
+ digestServer.close();
+ });
+});
Please sign in to comment.
Something went wrong with that request. Please try again.