javascriptazure-devopsbrowsercorsfetch-api

I want to detect in the client-side script that I have gotten a 401 error when fetching data with bad PAT (Using Azure DevOps REST API)


When I use an expired PAT attempting to access the REST end-point, in the browser's Dev Tools console window I will get:

GET https://dev.azure.com/contoso/_apis/connectionData net::ERR_FAILED 401 (Unauthorized)

But it is impossible to get the fact that there's a 401 status code from inside JavaScript. I've tried fetch with "then" / "catch" / and await with try/catch. I've also tried XMLHttpRequest. The only error I get is:

TypeError: Failed to fetch

Since the browser is clearly seeing the 401 (Unauthorized), I'd like to be detect that status code.

The browser Dev Tools console also has the following:

Access to fetch at 'https://dev.azure.com/contoso/_apis/connectionData' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

enter image description here

The Network tab shows the 401 status as well:

enter image description here

Using no-cors hides makes everything opaque, and you don't get the 401 status.

Here's a runnable sample:

<!DOCTYPE html>
<html>
  <body>
    <script>
(async function () {
  "use strict";

  const API_ORGANIZATION="contoso"

  const expiredPat = 'ts44tplifj4hbbzeitcz5dg5az4q3mpqcxqmrxo4zwlgy3scobzq'
  const pat = expiredPat

  // Fetch data from Azure DevOps
  async function ado(api) {
    const url = `https://dev.azure.com/${API_ORGANIZATION}/_apis/${api}`
    console.log(`url: ${url}`)
    const response = await fetch(url, {
      method: 'GET',
      cache: 'no-cache',
      mode: 'cors',
      headers: {
        Authorization: 'Basic ' + btoa(':' + pat),
        Accept: 'application/json',
      }
    })
    return await response.json()
  }

  // get the connectionData from Azure DevOps
  async function getConnectionData() {
    return await ado('connectionData')
  }

  function topText(text) {
    var p = document.createElement('p');
    p.innerHTML = text
    document.body.prepend(p)
    return p
  }

  // show the connection Data at the top of the window
  async function showConnectionData() {
    try {
      const result = await getConnectionData();
      topText(`Azure DevOps access authenticated as: ${result.authenticatedUser.providerDisplayName}`)
    } catch(err) {
      const p = topText(`${err} - See console`)
      p.style.color = "red";
      p.style.fontWeight = "999"
    }
  }

  async function tryFetch() {
    try {
      await showConnectionData()
    } catch(err) {
      console.log(err);
    }
  }

  document.addEventListener("DOMContentLoaded", function(event) {
    tryFetch()
  });

})();


    </script>
  </body>
</html>


Solution

  • The issue is caused by the CORS (Cross-Origin Resource Sharing).

    According to this official document about Azure DevOps REST API:

    Azure DevOps Services supports CORS, which enables JavaScript code served from a domain other than dev.azure.com/* to make Ajax requests to Azure DevOps Services REST APIs. Each request must provide credentials (PATs and OAuth access tokens are both supported options).

    This means that Azure DevOps Services supports CORS when you provided the correct credentials. When the PAT is incorrect, the server responds with a 401 Unauthorized status and since the credentials are invalid, the server does not proceed with the request and does not include CORS headers.

    For example, I tested the issue with the following script (replace the orgname and pat with the actual value):

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Azure DevOps CORS Example</title>
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
        <h1>Azure DevOps API Response</h1>
        <div id="status"></div>
        <div id="response"></div>
        <script>
            $(document).ready(function() {
                $.ajax({
                    url: 'https://dev.azure.com/{orgname}/_apis/connectionData',
                    dataType: 'json',
                    headers: {
                        'Authorization': 'Basic ' + btoa("" + ":" + "pat")
                    }
                }).done(function(results) {
                    $('#status').text('Status: 200 OK');
                    $('#response').text(JSON.stringify(results, null, 2));
                }).fail(function(jqXHR, textStatus, errorThrown) {
                    $('#status').text('Status: ' + jqXHR.status + ' ' + textStatus);
                    $('#response').text('Response: ' + errorThrown);
                });
            });
        </script>
    </body>
    </html>
    

    When run the script with a correct PAT, you can see the Access-Control-Allow-Origin:* in the response headers.

    correct PAT

    When run the script with an expired PAT, there is no Access-Control-Allow-Origin:* in the response headers.

    expired PAT

    To workaround the CORS issue, you can refer this question for more details.

    In my test, I tried to use the CORS Anywhere, a public demo of the CORS Anywhere service. It allows you to bypass the CORS restrictions when making requests to different domains. This should only be used for development purposes.

    Steps:

    1. Visit https://cors-anywhere.herokuapp.com/corsdemo and click the "Request temporary access to the demo server" button. You will have temporary access to the demo server.
    2. Add the https://cors-anywhere.herokuapp.com to the URL in the script, which becomes to https://cors-anywhere.herokuapp.com/https://dev.azure.com/orgname/_apis/connectionData
    3. Now we can find the Access-Control-Allow-Origin in the response headers and get the 401 status code.

    Test result: result