Integrations

Azure DevOps OAuth Apps and Personal Access Tokens

Implement Azure DevOps authentication with OAuth 2.0 apps, PAT management, and service principal patterns for Node.js

Azure DevOps OAuth Apps and Personal Access Tokens

Overview

Authentication is the gateway to every Azure DevOps integration you will ever build. Whether you are pulling work items into a dashboard, triggering pipelines from an external service, or building a multi-tenant SaaS product that connects to customer Azure DevOps organizations, you need to understand the authentication mechanisms available and when to use each one. This article walks through Personal Access Tokens, OAuth 2.0 app registration and authorization flows, and service principal authentication — all with working Node.js code you can deploy today.

Prerequisites

  • An Azure DevOps organization (free tier works fine)
  • Node.js 18 or later installed
  • An Azure Active Directory tenant (for OAuth and service principal flows)
  • Basic understanding of HTTP authentication headers and REST APIs
  • Familiarity with Express.js

Personal Access Tokens

PAT Creation and Scopes

Personal Access Tokens are the simplest authentication method in Azure DevOps. A PAT is essentially a password tied to your identity with a configurable set of permissions and an expiration date. You create them from the Azure DevOps portal under User Settings > Personal Access Tokens.

Every PAT requires at least one scope. Scopes control what the token can access. Here are the most commonly used ones:

Scope Description
vso.code Read access to source code and metadata
vso.code_write Read and write access to source code
vso.build_execute Read and execute builds
vso.work_write Read, create, and update work items
vso.release_manage Read, update, and delete release definitions
vso.packaging Read feeds and packages
vso.project Read projects and teams

The principle of least privilege applies here. If your integration only reads work items, do not grant code or build scopes. Every extra scope is an expanded blast radius if the token leaks.

Here is how you use a PAT to call the Azure DevOps REST API from Node.js:

var https = require("https");

var PAT = process.env.AZURE_DEVOPS_PAT;
var ORG = process.env.AZURE_DEVOPS_ORG;

function callAzureDevOps(path, callback) {
  var auth = Buffer.from(":" + PAT).toString("base64");

  var options = {
    hostname: "dev.azure.com",
    path: "/" + ORG + path,
    method: "GET",
    headers: {
      "Authorization": "Basic " + auth,
      "Content-Type": "application/json"
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      callback(null, JSON.parse(data));
    });
  });

  req.on("error", function(err) { callback(err); });
  req.end();
}

// List projects
callAzureDevOps("/_apis/projects?api-version=7.1", function(err, result) {
  if (err) {
    console.error("Request failed:", err.message);
    return;
  }
  result.value.forEach(function(project) {
    console.log(project.name, "-", project.id);
  });
});

Notice the authentication header format: it is Basic auth where the username is empty and the password is the PAT. The colon before the PAT is required — without it, the API returns a 203 Non-Authoritative Information response instead of a proper 401, which is one of the more confusing debugging experiences Azure DevOps offers.

PAT Lifecycle Management

PATs expire. The maximum lifetime is one year, and Microsoft has been tightening this. In organizations with strict security policies, administrators can enforce maximum PAT lifetimes and restrict the scopes users can grant.

Managing PAT lifecycles programmatically is critical for production systems. Azure DevOps provides a PAT Lifecycle Management API:

var https = require("https");

var AAD_TOKEN = process.env.AAD_ACCESS_TOKEN; // Azure AD token, not a PAT

function listPersonalAccessTokens(callback) {
  var options = {
    hostname: "vssps.dev.azure.com",
    path: "/" + process.env.AZURE_DEVOPS_ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
    method: "GET",
    headers: {
      "Authorization": "Bearer " + AAD_TOKEN,
      "Content-Type": "application/json"
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      var result = JSON.parse(data);
      callback(null, result.patTokens);
    });
  });

  req.on("error", function(err) { callback(err); });
  req.end();
}

function createPat(displayName, scopes, validTo, callback) {
  var body = JSON.stringify({
    displayName: displayName,
    scope: scopes.join(" "),
    validTo: validTo,
    allOrgs: false
  });

  var options = {
    hostname: "vssps.dev.azure.com",
    path: "/" + process.env.AZURE_DEVOPS_ORG + "/_apis/tokens/pats?api-version=7.1-preview.1",
    method: "POST",
    headers: {
      "Authorization": "Bearer " + AAD_TOKEN,
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      callback(null, JSON.parse(data));
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(body);
  req.end();
}

// Create a PAT that expires in 90 days
var expiry = new Date();
expiry.setDate(expiry.getDate() + 90);

createPat("CI Pipeline Token", ["vso.build_execute", "vso.code"], expiry.toISOString(), function(err, result) {
  if (err) {
    console.error("Failed to create PAT:", err.message);
    return;
  }
  console.log("Token created:", result.patToken.displayName);
  console.log("Expires:", result.patToken.validTo);
  // Store result.patToken.token securely — this is the only time you see it
});

A key detail: the PAT Lifecycle Management API itself requires an Azure AD bearer token, not a PAT. You cannot use a PAT to manage PATs. This is intentional — it prevents a compromised PAT from being used to create more PATs.

OAuth 2.0 App Registration

Registering Your Application

For user-facing applications where you need to act on behalf of users across multiple Azure DevOps organizations, OAuth 2.0 is the right choice. You register your app at https://app.vsaex.visualstudio.com/app/register.

During registration, you specify:

  • Company name and Application name: Displayed on the consent screen
  • Application website: Your app's homepage
  • Authorization callback URL: Where Azure DevOps redirects after user authorization
  • Authorized scopes: The maximum set of permissions your app can request

After registration, you receive an App ID and a Client Secret. Treat the client secret like a production database password — it should never appear in client-side code, version control, or logs.

OAuth Authorization Flow in Node.js

The Azure DevOps OAuth flow follows the standard authorization code grant pattern with a few quirks. Here is a complete implementation:

var express = require("express");
var https = require("https");
var querystring = require("querystring");
var crypto = require("crypto");

var app = express();

var CLIENT_ID = process.env.AZURE_DEVOPS_APP_ID;
var CLIENT_SECRET = process.env.AZURE_DEVOPS_CLIENT_SECRET;
var CALLBACK_URL = process.env.AZURE_DEVOPS_CALLBACK_URL;
var SCOPES = "vso.code vso.work_write vso.build_execute";

// Store state parameters to prevent CSRF
var pendingStates = {};

app.get("/auth/azure-devops", function(req, res) {
  var state = crypto.randomBytes(16).toString("hex");
  pendingStates[state] = { created: Date.now() };

  // Clean up stale states older than 10 minutes
  var now = Date.now();
  Object.keys(pendingStates).forEach(function(key) {
    if (now - pendingStates[key].created > 600000) {
      delete pendingStates[key];
    }
  });

  var authUrl = "https://app.vssps.visualstudio.com/oauth2/authorize?" + querystring.stringify({
    client_id: CLIENT_ID,
    response_type: "Assertion",
    state: state,
    scope: SCOPES,
    redirect_uri: CALLBACK_URL
  });

  res.redirect(authUrl);
});

app.get("/auth/azure-devops/callback", function(req, res) {
  var code = req.query.code;
  var state = req.query.state;

  if (!state || !pendingStates[state]) {
    return res.status(403).send("Invalid state parameter — possible CSRF attack");
  }
  delete pendingStates[state];

  if (!code) {
    return res.status(400).send("Authorization failed: " + (req.query.error_description || "Unknown error"));
  }

  exchangeCodeForToken(code, function(err, tokenData) {
    if (err) {
      return res.status(500).send("Token exchange failed: " + err.message);
    }
    // Store tokens securely — see token storage section below
    req.session = req.session || {};
    req.session.accessToken = tokenData.access_token;
    req.session.refreshToken = tokenData.refresh_token;
    req.session.tokenExpiry = Date.now() + (tokenData.expires_in * 1000);

    res.redirect("/dashboard");
  });
});

function exchangeCodeForToken(code, callback) {
  var body = querystring.stringify({
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: CLIENT_SECRET,
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    assertion: code,
    redirect_uri: CALLBACK_URL
  });

  var options = {
    hostname: "app.vssps.visualstudio.com",
    path: "/oauth2/token",
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode !== 200) {
        return callback(new Error("Token endpoint returned " + res.statusCode + ": " + data));
      }
      callback(null, JSON.parse(data));
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(body);
  req.end();
}

Notice a critical difference from standard OAuth: Azure DevOps uses client_assertion instead of client_secret, and the grant type is a JWT bearer assertion, not a standard authorization code. The response_type is Assertion, not code. These deviations trip up developers who copy OAuth implementations from other providers.

Token Refresh Handling

OAuth access tokens from Azure DevOps expire after one hour. Your application must handle refresh seamlessly. Here is a robust refresh implementation:

function refreshAccessToken(refreshToken, callback) {
  var body = querystring.stringify({
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: CLIENT_SECRET,
    grant_type: "refresh_token",
    assertion: refreshToken,
    redirect_uri: CALLBACK_URL
  });

  var options = {
    hostname: "app.vssps.visualstudio.com",
    path: "/oauth2/token",
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode !== 200) {
        return callback(new Error("Refresh failed: " + data));
      }
      var tokenData = JSON.parse(data);
      callback(null, {
        accessToken: tokenData.access_token,
        refreshToken: tokenData.refresh_token,
        expiresAt: Date.now() + (tokenData.expires_in * 1000)
      });
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(body);
  req.end();
}

function getValidToken(session, callback) {
  // Refresh 5 minutes before expiry to avoid race conditions
  var bufferMs = 5 * 60 * 1000;

  if (session.tokenExpiry && (Date.now() + bufferMs) < session.tokenExpiry) {
    return callback(null, session.accessToken);
  }

  if (!session.refreshToken) {
    return callback(new Error("No refresh token available — user must re-authorize"));
  }

  refreshAccessToken(session.refreshToken, function(err, tokens) {
    if (err) {
      return callback(err);
    }
    session.accessToken = tokens.accessToken;
    session.refreshToken = tokens.refreshToken;
    session.tokenExpiry = tokens.expiresAt;
    callback(null, tokens.accessToken);
  });
}

One important detail: when you refresh a token, Azure DevOps issues a new refresh token and invalidates the old one. If you fail to store the new refresh token, the user will have to re-authorize. This is the single most common bug in Azure DevOps OAuth integrations.

Service Principal Authentication

Service principals are the right choice for automated processes that do not act on behalf of a user. Think CI/CD integrations, scheduled jobs, and background services. They represent an application identity, not a human identity.

Setting up a service principal for Azure DevOps involves:

  1. Register an application in Azure Active Directory
  2. Create a client secret or certificate for the application
  3. Add the service principal to your Azure DevOps organization
  4. Assign appropriate permissions
var https = require("https");
var querystring = require("querystring");

var TENANT_ID = process.env.AZURE_TENANT_ID;
var CLIENT_ID = process.env.AZURE_SP_CLIENT_ID;
var CLIENT_SECRET = process.env.AZURE_SP_CLIENT_SECRET;

function getServicePrincipalToken(callback) {
  var body = querystring.stringify({
    grant_type: "client_credentials",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    scope: "499b84ac-1321-427f-aa17-267ca6975798/.default"
  });

  var options = {
    hostname: "login.microsoftonline.com",
    path: "/" + TENANT_ID + "/oauth2/v2.0/token",
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode !== 200) {
        return callback(new Error("Service principal auth failed: " + data));
      }
      var result = JSON.parse(data);
      callback(null, {
        accessToken: result.access_token,
        expiresAt: Date.now() + (result.expires_in * 1000)
      });
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(body);
  req.end();
}

The scope 499b84ac-1321-427f-aa17-267ca6975798 is the well-known resource ID for Azure DevOps. You append /.default to request all permissions assigned to the service principal in Azure AD.

Choosing Between PAT, OAuth, and Service Principal

This decision matters more than most developers think. Here is my framework:

Use Case Recommended Auth Why
Personal scripts and tools PAT Simple, quick to set up, tied to your identity
CI/CD pipelines Service Principal No human identity dependency, centrally managed
User-facing web app OAuth 2.0 Acts on behalf of users, proper consent flow
Multi-tenant SaaS product OAuth 2.0 Each customer authorizes independently
Background daemon services Service Principal Client credentials flow, no user interaction
Quick prototyping PAT Fastest path to a working integration
Organizational automation Service Principal Survives employee turnover

The biggest mistake I see teams make is using a shared PAT in CI/CD pipelines. When the employee who created the PAT leaves the company, every pipeline that depends on it breaks simultaneously. Use service principals for anything that outlives a single engineer.

Secure Token Storage Patterns

Never store tokens in plain text, in environment variables on shared servers, or in application logs. Here are patterns that work in production:

var crypto = require("crypto");

var ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32-byte hex string
var ALGORITHM = "aes-256-gcm";

function encryptToken(token) {
  var iv = crypto.randomBytes(16);
  var key = Buffer.from(ENCRYPTION_KEY, "hex");
  var cipher = crypto.createCipheriv(ALGORITHM, key, iv);

  var encrypted = cipher.update(token, "utf8", "hex");
  encrypted += cipher.final("hex");

  var authTag = cipher.getAuthTag().toString("hex");

  return iv.toString("hex") + ":" + authTag + ":" + encrypted;
}

function decryptToken(encryptedString) {
  var parts = encryptedString.split(":");
  var iv = Buffer.from(parts[0], "hex");
  var authTag = Buffer.from(parts[1], "hex");
  var encrypted = parts[2];

  var key = Buffer.from(ENCRYPTION_KEY, "hex");
  var decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);

  var decrypted = decipher.update(encrypted, "hex", "utf8");
  decrypted += decipher.final("utf8");

  return decrypted;
}

For production systems, use a dedicated secrets manager like Azure Key Vault, HashiCorp Vault, or AWS Secrets Manager. The encryption above is a reasonable fallback for simpler deployments where a secrets manager adds too much operational complexity.

Token Rotation Automation

PATs should be rotated regularly. Automating this prevents the "the token expired and everything is broken" panic that happens at 2 AM:

var CronJob = require("cron").CronJob;

function rotatePat(currentTokenId, displayName, scopes, callback) {
  // Create new token before revoking old one
  var expiry = new Date();
  expiry.setDate(expiry.getDate() + 90);

  createPat(displayName + " (rotated " + new Date().toISOString().slice(0, 10) + ")", scopes, expiry.toISOString(), function(err, newToken) {
    if (err) {
      console.error("Failed to create replacement token:", err.message);
      return callback(err);
    }

    // Update dependent systems with new token BEFORE revoking old one
    updateDependentSystems(newToken.patToken.token, function(updateErr) {
      if (updateErr) {
        console.error("Failed to update dependent systems — keeping old token active");
        return callback(updateErr);
      }

      // Only revoke old token after confirming new one works
      revokePat(currentTokenId, function(revokeErr) {
        if (revokeErr) {
          console.warn("Old token revocation failed — it will expire naturally");
        }
        callback(null, newToken);
      });
    });
  });
}

// Run rotation check weekly
var rotationJob = new CronJob("0 3 * * 1", function() {
  console.log("Running weekly PAT rotation check...");
  listPersonalAccessTokens(function(err, tokens) {
    if (err) {
      console.error("Failed to list tokens:", err.message);
      return;
    }

    var thirtyDaysFromNow = new Date();
    thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);

    tokens.forEach(function(token) {
      var expiresAt = new Date(token.validTo);
      if (expiresAt < thirtyDaysFromNow) {
        console.log("Token", token.displayName, "expires soon — rotating...");
        rotatePat(token.authorizationId, token.displayName, token.scope, function(err) {
          if (err) {
            console.error("Rotation failed for", token.displayName);
          } else {
            console.log("Rotated", token.displayName, "successfully");
          }
        });
      }
    });
  });
}, null, true, "UTC");

The key insight here is the create-then-revoke pattern. Never revoke the old token before the new one is confirmed working. An atomic swap is not possible with PATs, so you accept a brief window where both tokens are valid.

Auditing Token Usage

You should know which tokens are being used, when, and from where. Azure DevOps provides audit logs that you can query:

function getAuditLog(startTime, endTime, callback) {
  var path = "/_apis/audit/auditlog?startTime=" +
    encodeURIComponent(startTime.toISOString()) +
    "&endTime=" + encodeURIComponent(endTime.toISOString()) +
    "&api-version=7.1";

  callAzureDevOps(path, function(err, result) {
    if (err) return callback(err);

    var tokenEvents = result.decoratedAuditLogEntries.filter(function(entry) {
      return entry.actionId.indexOf("Token") !== -1;
    });

    callback(null, tokenEvents);
  });
}

// Check for suspicious token activity in the last 24 hours
var now = new Date();
var yesterday = new Date(now.getTime() - 86400000);

getAuditLog(yesterday, now, function(err, events) {
  if (err) {
    console.error("Audit query failed:", err.message);
    return;
  }

  events.forEach(function(event) {
    console.log("[%s] %s by %s from %s",
      event.timestamp,
      event.actionId,
      event.actorDisplayName,
      event.ipAddress
    );
  });
});

Building an OAuth Middleware with Express.js

For production applications, you want a reusable middleware that handles token validation and refresh transparently:

var express = require("express");

function azureDevOpsAuth(options) {
  var clientSecret = options.clientSecret;
  var callbackUrl = options.callbackUrl;
  var tokenStore = options.tokenStore; // interface: get(userId), set(userId, tokens)

  return function(req, res, next) {
    var userId = req.session && req.session.userId;

    if (!userId) {
      return res.status(401).json({ error: "Not authenticated" });
    }

    tokenStore.get(userId, function(err, tokens) {
      if (err || !tokens) {
        return res.status(401).json({ error: "No Azure DevOps authorization found" });
      }

      var bufferMs = 5 * 60 * 1000;
      if (Date.now() + bufferMs < tokens.expiresAt) {
        req.azureDevOpsToken = tokens.accessToken;
        return next();
      }

      // Token needs refresh
      refreshAccessToken(tokens.refreshToken, function(refreshErr, newTokens) {
        if (refreshErr) {
          return res.status(401).json({ error: "Token refresh failed — re-authorization required" });
        }

        tokenStore.set(userId, {
          accessToken: newTokens.accessToken,
          refreshToken: newTokens.refreshToken,
          expiresAt: newTokens.expiresAt
        }, function(storeErr) {
          if (storeErr) {
            console.error("Failed to persist refreshed tokens:", storeErr.message);
          }
          req.azureDevOpsToken = newTokens.accessToken;
          next();
        });
      });
    });
  };
}

// Usage
var authMiddleware = azureDevOpsAuth({
  clientSecret: process.env.AZURE_DEVOPS_CLIENT_SECRET,
  callbackUrl: process.env.AZURE_DEVOPS_CALLBACK_URL,
  tokenStore: myDatabaseTokenStore
});

app.get("/api/projects", authMiddleware, function(req, res) {
  // req.azureDevOpsToken is guaranteed to be valid here
  callAzureDevOpsWithBearer(req.azureDevOpsToken, "/_apis/projects?api-version=7.1", function(err, projects) {
    if (err) return res.status(500).json({ error: err.message });
    res.json(projects);
  });
});

Multi-Tenant OAuth Apps

Building a multi-tenant application that connects to different customers' Azure DevOps organizations requires careful tenant isolation. Each customer goes through their own OAuth consent flow, and you store their tokens separately:

var tokenStoreDb = require("./db"); // your database module

var multiTenantTokenStore = {
  get: function(tenantId, callback) {
    tokenStoreDb.query(
      "SELECT encrypted_access_token, encrypted_refresh_token, expires_at FROM azure_devops_tokens WHERE tenant_id = $1",
      [tenantId],
      function(err, rows) {
        if (err || rows.length === 0) return callback(err || new Error("No tokens"));
        var row = rows[0];
        callback(null, {
          accessToken: decryptToken(row.encrypted_access_token),
          refreshToken: decryptToken(row.encrypted_refresh_token),
          expiresAt: new Date(row.expires_at).getTime()
        });
      }
    );
  },

  set: function(tenantId, tokens, callback) {
    tokenStoreDb.query(
      "INSERT INTO azure_devops_tokens (tenant_id, encrypted_access_token, encrypted_refresh_token, expires_at) " +
      "VALUES ($1, $2, $3, $4) " +
      "ON CONFLICT (tenant_id) DO UPDATE SET " +
      "encrypted_access_token = EXCLUDED.encrypted_access_token, " +
      "encrypted_refresh_token = EXCLUDED.encrypted_refresh_token, " +
      "expires_at = EXCLUDED.expires_at",
      [
        tenantId,
        encryptToken(tokens.accessToken),
        encryptToken(tokens.refreshToken),
        new Date(tokens.expiresAt).toISOString()
      ],
      callback
    );
  }
};

// Tenant-scoped authorization endpoint
app.get("/auth/azure-devops/:tenantId", function(req, res) {
  var tenantId = req.params.tenantId;
  var state = crypto.randomBytes(16).toString("hex");

  pendingStates[state] = { tenantId: tenantId, created: Date.now() };

  var authUrl = "https://app.vssps.visualstudio.com/oauth2/authorize?" + querystring.stringify({
    client_id: CLIENT_ID,
    response_type: "Assertion",
    state: state,
    scope: SCOPES,
    redirect_uri: CALLBACK_URL
  });

  res.redirect(authUrl);
});

app.get("/auth/azure-devops/callback", function(req, res) {
  var state = req.query.state;
  var code = req.query.code;

  if (!state || !pendingStates[state]) {
    return res.status(403).send("Invalid state");
  }

  var tenantId = pendingStates[state].tenantId;
  delete pendingStates[state];

  exchangeCodeForToken(code, function(err, tokenData) {
    if (err) return res.status(500).send("Token exchange failed");

    multiTenantTokenStore.set(tenantId, {
      accessToken: tokenData.access_token,
      refreshToken: tokenData.refresh_token,
      expiresAt: Date.now() + (tokenData.expires_in * 1000)
    }, function(storeErr) {
      if (storeErr) return res.status(500).send("Failed to store tokens");
      res.redirect("/tenants/" + tenantId + "/dashboard");
    });
  });
});

The tenant isolation here happens at the database level. Every token query is scoped to a tenant ID. Never let one tenant's API calls use another tenant's token — this is the kind of bug that ends up in security breach disclosures.

Complete Working Example

Here is a full Express application that ties everything together — OAuth authorization, token management, refresh handling, and authenticated API calls:

var express = require("express");
var session = require("express-session");
var crypto = require("crypto");
var https = require("https");
var querystring = require("querystring");

var app = express();

app.use(session({
  secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex"),
  resave: false,
  saveUninitialized: false,
  cookie: { secure: process.env.NODE_ENV === "production", httpOnly: true, maxAge: 86400000 }
}));

var CONFIG = {
  clientId: process.env.AZURE_DEVOPS_APP_ID,
  clientSecret: process.env.AZURE_DEVOPS_CLIENT_SECRET,
  callbackUrl: process.env.AZURE_DEVOPS_CALLBACK_URL || "http://localhost:3000/auth/callback",
  scopes: "vso.code vso.work_write vso.project"
};

var pendingStates = {};

// --- Auth Routes ---

app.get("/auth/login", function(req, res) {
  var state = crypto.randomBytes(16).toString("hex");
  pendingStates[state] = { created: Date.now() };

  var url = "https://app.vssps.visualstudio.com/oauth2/authorize?" + querystring.stringify({
    client_id: CONFIG.clientId,
    response_type: "Assertion",
    state: state,
    scope: CONFIG.scopes,
    redirect_uri: CONFIG.callbackUrl
  });

  res.redirect(url);
});

app.get("/auth/callback", function(req, res) {
  var state = req.query.state;
  var code = req.query.code;

  if (!state || !pendingStates[state]) {
    return res.status(403).send("Invalid state parameter");
  }
  delete pendingStates[state];

  if (!code) {
    return res.status(400).send("Authorization denied: " + (req.query.error_description || "Unknown error"));
  }

  tokenExchange(code, function(err, tokens) {
    if (err) {
      console.error("Token exchange error:", err.message);
      return res.status(500).send("Authentication failed");
    }

    req.session.tokens = {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      expiresAt: Date.now() + (tokens.expires_in * 1000)
    };

    res.redirect("/dashboard");
  });
});

app.get("/auth/logout", function(req, res) {
  req.session.destroy(function() {
    res.redirect("/");
  });
});

// --- Token Management ---

function tokenExchange(assertion, callback) {
  var body = querystring.stringify({
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: CONFIG.clientSecret,
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    assertion: assertion,
    redirect_uri: CONFIG.callbackUrl
  });

  postToTokenEndpoint(body, callback);
}

function tokenRefresh(refreshToken, callback) {
  var body = querystring.stringify({
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: CONFIG.clientSecret,
    grant_type: "refresh_token",
    assertion: refreshToken,
    redirect_uri: CONFIG.callbackUrl
  });

  postToTokenEndpoint(body, callback);
}

function postToTokenEndpoint(body, callback) {
  var options = {
    hostname: "app.vssps.visualstudio.com",
    path: "/oauth2/token",
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode !== 200) {
        return callback(new Error("Token endpoint error " + res.statusCode + ": " + data));
      }
      callback(null, JSON.parse(data));
    });
  });

  req.on("error", callback);
  req.write(body);
  req.end();
}

// --- Auth Middleware ---

function requireAuth(req, res, next) {
  if (!req.session.tokens) {
    return res.redirect("/auth/login");
  }

  var tokens = req.session.tokens;
  var buffer = 5 * 60 * 1000;

  if (Date.now() + buffer < tokens.expiresAt) {
    req.devopsToken = tokens.accessToken;
    return next();
  }

  tokenRefresh(tokens.refreshToken, function(err, newTokens) {
    if (err) {
      console.error("Token refresh failed:", err.message);
      req.session.destroy(function() {
        res.redirect("/auth/login");
      });
      return;
    }

    req.session.tokens = {
      accessToken: newTokens.access_token,
      refreshToken: newTokens.refresh_token,
      expiresAt: Date.now() + (newTokens.expires_in * 1000)
    };

    req.devopsToken = newTokens.access_token;
    next();
  });
}

// --- Azure DevOps API Helper ---

function devopsApi(token, org, path, callback) {
  var options = {
    hostname: "dev.azure.com",
    path: "/" + org + path,
    method: "GET",
    headers: {
      "Authorization": "Bearer " + token,
      "Content-Type": "application/json"
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode === 401) {
        return callback(new Error("Unauthorized — token may be expired"));
      }
      if (res.statusCode === 403) {
        return callback(new Error("Forbidden — insufficient scope"));
      }
      callback(null, JSON.parse(data));
    });
  });

  req.on("error", callback);
  req.end();
}

// --- Application Routes ---

app.get("/", function(req, res) {
  if (req.session.tokens) {
    return res.redirect("/dashboard");
  }
  res.send("<h1>Azure DevOps Integration</h1><a href=\"/auth/login\">Connect Azure DevOps</a>");
});

app.get("/dashboard", requireAuth, function(req, res) {
  devopsApi(req.devopsToken, req.query.org || "myorg", "/_apis/projects?api-version=7.1", function(err, result) {
    if (err) {
      return res.status(500).send("API error: " + err.message);
    }

    var html = "<h1>Your Projects</h1><ul>";
    result.value.forEach(function(project) {
      html += "<li><strong>" + project.name + "</strong> — " + (project.description || "No description") + "</li>";
    });
    html += "</ul><br><a href=\"/auth/logout\">Disconnect</a>";
    res.send(html);
  });
});

app.get("/api/work-items", requireAuth, function(req, res) {
  var org = req.query.org;
  var project = req.query.project;

  if (!org || !project) {
    return res.status(400).json({ error: "org and project query parameters required" });
  }

  var wiqlPath = "/" + project + "/_apis/wit/wiql?api-version=7.1";

  var query = JSON.stringify({
    query: "SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.TeamProject] = '" + project + "' ORDER BY [System.ChangedDate] DESC"
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + org + wiqlPath,
    method: "POST",
    headers: {
      "Authorization": "Bearer " + req.devopsToken,
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(query)
    }
  };

  var apiReq = https.request(options, function(apiRes) {
    var data = "";
    apiRes.on("data", function(chunk) { data += chunk; });
    apiRes.on("end", function() {
      res.json(JSON.parse(data));
    });
  });

  apiReq.on("error", function(err) {
    res.status(500).json({ error: err.message });
  });
  apiReq.write(query);
  apiReq.end();
});

var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
  console.log("Azure DevOps OAuth app listening on port " + PORT);
});

Common Issues and Troubleshooting

1. "203 Non-Authoritative Information" instead of 401

This happens when you send a PAT without the colon prefix in the Basic auth header. The correct format is Basic base64(":" + pat). Without the leading colon, Azure DevOps returns a 203 with an HTML sign-in page instead of a proper authentication error. This is one of the most confusing behaviors in the API.

2. "The resource cannot be found" after OAuth callback

The most common cause is a mismatch between the callback URL registered in your app and the one you pass in the token exchange request. Azure DevOps requires an exact match, including trailing slashes and protocol. http://localhost:3000/callback is not the same as http://localhost:3000/callback/. Check both the redirect_uri in your authorize request and your token exchange request.

3. Refresh token silently stops working

Azure DevOps refresh tokens can be revoked by an organization administrator without notification. When a user's access to an organization is removed or their Azure AD account is disabled, existing refresh tokens become invalid. Your application must handle this gracefully by detecting the refresh failure and redirecting to re-authorization instead of crashing in a retry loop.

4. PAT works in browser but fails in code

This usually means you are hitting the wrong hostname. Azure DevOps has multiple API surfaces: dev.azure.com for the main REST API, vssps.dev.azure.com for identity and profile APIs, vsaex.visualstudio.com for extension management, and app.vssps.visualstudio.com for OAuth token endpoints. Using the wrong hostname results in valid-looking 401 errors that have nothing to do with your credentials.

5. OAuth scope errors after successful authorization

You requested scopes during authorization, but the OAuth app registration might not include those scopes. The scopes in your authorization URL must be a subset of the scopes configured in the app registration at app.vsaex.visualstudio.com. If a scope is missing from the registration, it is silently ignored — your token will work but lack the permissions you expected.

Best Practices

  • Never embed tokens in source code. Use environment variables at minimum, and a secrets manager for production. Even in private repositories, tokens in code are one git push away from a public incident.

  • Set the shortest viable expiration for PATs. If your integration runs daily, a 30-day PAT with automated rotation is better than a 365-day PAT that everyone forgets about.

  • Use service principals for automation, not personal PATs. When an employee leaves, their PATs are revoked. If your CI/CD pipeline depends on someone's personal PAT, you get a production outage as a farewell gift.

  • Always validate the state parameter in OAuth callbacks. Skipping CSRF protection in OAuth flows is a security vulnerability that allows attackers to associate their Azure DevOps account with your user's session.

  • Store refresh tokens encrypted at rest. A leaked refresh token is nearly as dangerous as a leaked password — it provides ongoing access until explicitly revoked.

  • Implement token refresh with a time buffer. Refreshing 5 minutes before expiry prevents race conditions where a token expires mid-request. Waiting until you get a 401 means you already have a failed request to retry.

  • Log token lifecycle events but never log token values. Knowing when tokens were created, refreshed, and revoked is invaluable for debugging. But logging the actual token values defeats every other security measure you have in place.

  • Use the principle of least privilege for all token types. Request only the scopes your application needs. Review scopes periodically — requirements change, and yesterday's necessary permission might be today's unnecessary risk.

  • Implement circuit breakers around token refresh. If refresh fails three times in a row, stop retrying and alert. Infinite retry loops against the token endpoint can get your application's IP rate-limited or blocked.

References

Powered by Contentful