Digest authentication and Google Apps Script

The other day I was looking around for an example of how to do digest authentication in Google Apps Script (or even plain javascript). I found plenty of theoretical discussions on it, but no actual examples. So here’s an implementation walkthrough. You can include it live in your Google Apps Script Project from the mcpher shared library  or just copy the code.

What is Digest Authentication

Working with GAS, you’ll probably be more familiar with oAuth2, which is supported well through the UrlFetchApp class. However some APIS still use Basic authentication, or the slightly more secure Digest Authentication. Here is a wikipedia write up of it, and here is the RFC describing Digest Authentication in detail.

Worked example

We’ll use the real estate exchange format, defined here , as an example.

Step 1. Initialize the workflow

  // kick off workflow , expecting a 401
  this.initWorkflow = function () {
      this.danceStep1 = UrlFetchApp.fetch(this.url, 
                      { "method" : "GET", "muteHttpExceptions": true} );
      return this;
  };

 

Notes
  • muteHttpExceptions is (I think undocumented) option that prevents urlfetch from crashing out and returning a null response
  • this.danceStep1 WWW-Authenticate header looks like this, and a 401 status code is returned. What we need from this to construct the next request is the nonce, qop, and realm from the WWW-Authenticate header. Other implementations also return a few other things like encoding algorithm, domain and the opaque value.
  • It’s quite fiddly to parse the header- for some fields there are quotes – for others there are not. I wont go into the details of that here, but the parsing code is included in the code implementation.
  "WWW-Authenticate": "Digest realm="CREA.Distribution", nonce="NjM1MDQ3NzA4NDAxNzguNDpjZWQ0N2U4ODI3ZGZhYmQ4ODRkZWJhZmM5ZjllYTYwYg==", qop="auth"",

Step 2 – construct the digest

Using this code, we construct a digest header like this as per  Digest Authentication in detail.
  o.algorithm = "MD5";
      var HA1 = bytesToHex(md5(this.credentials.username+':'+o.realm+':'+this.credentials.password));
      var HA2 = bytesToHex(md5('GET'+':'+o.domain ));
      var response = bytesToHex(md5(HA1+':'+o.nonce+":" + nc+":"+this.cnonce+":"+o.qop+":"+HA2));
  
      var digest = 'Digest username="' + this.credentials.username + '"' +
             ',realm="' + o.realm + '"' +
             ',nonce="' + o.nonce + '"' +
             ',uri="' + o.domain + '"' +
             ',qop=' + o.qop + 
             ',nc=' + nc  +
             ',algorithm=' + o.algorithm +
             ',cnonce="' + this.cnonce + '"' + 
             ',response="' + response + '"';

    // sometimes opaque not given
      if (o.opaque) digest += ',opaque="' + o.opaque + '"';

 

Some of the intermediate values look like this,
HA1 cfee3113c2f3ebf16863cddbc6bdcd33
HA2 e85bcbec0349f876e2ee5676ade2db50
RESPONSE a2c9f7eb682776be2ca152c4c3299d03

Step 3 – Finish the workflow

Now we can respond to that 401, this time we should get a 200 ok reponse
  this.finishWorkflow = function () {
      var options = 
       { "method" : "GET", "muteHttpExceptions": true , 
         "headers" : {
           "Authorization" : this.digest() 
         }
       }
      this.danceStep2 = UrlFetchApp.fetch(this.url, options);
      return this;
  }

Where this.digest() looks like this

{
    "method": "GET",
    "muteHttpExceptions": true,
    "headers": {
        "Authorization": 
        "Digest username="CXLHfDVrziCfvwgCuL8nUahC",
          realm="CREA.Distribution",
          nonce="NjM1MDQ3NzA4NDAxNzguNDpjZWQ0N2U4ODI3ZGZhYmQ4ODRkZWJhZmM5ZjllYTYwYg==",
          uri="undefined",
          qop=auth,
          nc=00000001,
          algorithm=MD5,
          cnonce="13ec921de96",
          response="a2c9f7eb682776be2ca152c4c3299d03""
    }
}

The Code

The digest auth code is accessible through the cDigestAuth object which you can include in your project from the mcpher shared library like this, or just copy the code from here.
Here’s the test
//---Digest authentication in Google Apps Script
function digestTest() {
  var url = "http://sample.data.crea.ca/Login.svc/Login";
  var creds = getRetsCredentials("tester");
  
  // do digest auth dance
  var d = new mcpher.cDigestAuth (url,creds).login();           
  // do something
  if (d.isLoggedIn()) {
     // do something .. we're now logged in
  }
  else {
    Logger.log("all is bad");
    Logger.log ("failed to log in " + JSON.stringify(d));
  }
}

your credentials should come from some secret place, for example scriptDB or script properties, and should return an object like this

 {username:"CXLHfDVrziCfvwgCuL8nUahC",
       password:"mFqMsCSPdnb5WO1gpEEtDCHH"}

Here’s the complete code for cDigestAuth

//---class to to the authentication
function cDigestAuth(url, credentials) {
  
  // implementation of http://tools.ietf.org/html/rfc2617
  this.credentials = credentials;  
  this.url = url;
  
  // workflow is try and get expected 401, construct digest and try again - should be 200
  this.login = function () {
    
    this.cnonce = new Date().getTime().toString(16);
    return this.initWorkflow()
                .finishWorkflow();
  };
  
  // if all has gone well we'll have got a 200 on 2nd part of workflow
  this.isLoggedIn = function () {
    return (this.danceStep2 && this.danceStep2.getResponseCode() == 200) ;
  };
  
  // kick off workflow , expecting a 401
  this.initWorkflow = function () {
      this.danceStep1 = UrlFetchApp.fetch(this.url, 
                      { "method" : "GET", "muteHttpExceptions": true} );
      return this;
  };
  
  // finish the workflow, expecting a 200
  this.finishWorkflow = function () {
      var options = 
       { "method" : "GET", "muteHttpExceptions": true , 
         "headers" : {
           "Authorization" : this.digest() 
         }
       }
      this.danceStep2 = UrlFetchApp.fetch(this.url, options);
      return this;
  }
  
  // this the hard work - figuring out the digest
  this.digest = function () {
    // now we can handshake
      var nc= "00000001";
      var o = this.authSplit();
      
    // we only know how to do md5 - TODO decide how to handle
      if (o.algorithm && o.algorithm != "MD5") {
        throw ("unable to deal with requested algorithm " + o.algorithm);
      }
      
      o.algorithm = "MD5";
      var HA1 = bytesToHex(md5(this.credentials.username+':'+o.realm+':'+this.credentials.password));
      var HA2 = bytesToHex(md5('GET'+':'+o.domain ));
      var response = bytesToHex(md5(HA1+':'+o.nonce+":" + nc+":"+this.cnonce+":"+o.qop+":"+HA2));
  
      var digest = 'Digest username="' + this.credentials.username + '"' +
             ',realm="' + o.realm + '"' +
             ',nonce="' + o.nonce + '"' +
             ',uri="' + o.domain + '"' +
             ',qop=' + o.qop + 
             ',nc=' + nc  +
             ',algorithm=' + o.algorithm +
             ',cnonce="' + this.cnonce + '"' + 
             ',response="' + response + '"';

    // sometimes opaque not given
      if (o.opaque) digest += ',opaque="' + o.opaque + '"';

      return digest;

  };
  
  // parse that very messy authenticate header
  this.authSplit = function () {
    return authSplit(this.danceStep1.getHeaders()["WWW-Authenticate"]);
  }

  // contains session ID etc.
  this.header200 = function () {
    return this.danceStep2.getHeaders();
  }

  // some utilities
  function md5 (s) {
    return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, s);
  }
  
  
  function bytesToHex(b) {
    var s = '';

    for (var i =0 ; i < b.length;i++) {
      var by = b[i]<0 ? b[i]+256:b[i];
      var t= maskString(by.toString(16),"00");
      s += t;
    }
    return s;
  }
  
  function maskString(sIn , f ) {
    var s = sIn.replace(/^\s\s*/, "").replace(/\s\s*$/, "");
    if (s.length < f.length) {
        s = f.slice ( 0, f.length- s.length)  + s ;
    }
    return s;
  }
  function authSplit  (aHeader, optSplitChar) {
  
    var a = aHeader.replace(/^"|"$/g,"").replace(/^Digest /,"").split(optSplitChar || ",");
    var o = {};
    for (var i =0; i < a.length ; i++ ) {
      // trims then splits on = ignoring - in double quotes, and knocking off quotes if present
      var b = a[i].replace(/^\s\s*/, "").replace(/\s\s*$/, "").match(/(".*?"|[^"=\s]+)(?=\s*=|\s*$)/g) ;   
      if (b.length == 2 ) {
        o[b[0]] = b[1].replace(/^"|"$/g,"");
      }
      else
        throw("error in WWW-Authenticate response " + a.join());
      
    }
    return o;
  }
}
About brucemcp 225 Articles
I am a Google Developer Expert and decided to investigate Google Apps Script in my spare time. The more I investigated the more content I created so this site is extremely rich. Now, in 2019, a lot of things have disappeared or don’t work anymore due to Google having retired some stuff. I am however leaving things as is and where I came across some deprecated stuff, I have indicated it. I decided to write a book about it and to also create videos to teach developers who want to learn Google Apps Script. If you find the material contained in this site useful, you can support me by buying my books and or videos.