Security

OAuth Application Development for Azure DevOps

Build secure OAuth applications that integrate with Azure DevOps, implementing the complete authorization flow with Node.js

OAuth Application Development for Azure DevOps

OAuth 2.0 is the standard mechanism for building third-party applications that interact with Azure DevOps on behalf of users. Unlike Personal Access Tokens, which are tied to a single user and require manual rotation, OAuth applications allow your software to request scoped permissions, authenticate multiple users, and manage token lifecycles programmatically. If you are building a dashboard, CI/CD integration, reporting tool, or any multi-user application that touches Azure DevOps, OAuth is the correct approach.

Prerequisites

  • An Azure DevOps organization with administrative access
  • Node.js v16 or later installed
  • An Azure DevOps account registered at https://app.vsaex.visualstudio.com
  • Basic understanding of HTTP redirects and OAuth 2.0 concepts
  • A publicly accessible callback URL (or ngrok for local development)

Understanding the Azure DevOps OAuth 2.0 Flow

Azure DevOps implements the OAuth 2.0 Authorization Code Grant flow. This is not the newer Azure AD (Entra ID) OAuth flow — it is the legacy Visual Studio OAuth model that remains widely used and is the primary mechanism documented under the Azure DevOps REST API. Understanding the distinction matters because the endpoints, scopes, and token formats are entirely different.

The flow works as follows:

  1. Your application redirects the user to the Azure DevOps authorization endpoint
  2. The user signs in and grants your application the requested permissions
  3. Azure DevOps redirects back to your callback URL with an authorization code
  4. Your application exchanges that code for an access token and refresh token
  5. You use the access token to call Azure DevOps REST APIs
  6. When the access token expires, you use the refresh token to get a new one

The access token lifetime is one hour. The refresh token lifetime is one year, but it gets renewed each time you use it. This means a well-implemented application can maintain access indefinitely without requiring the user to re-authorize.

Registering Your OAuth Application

Before writing any code, you need to register your application with Azure DevOps. Navigate to https://app.vsaex.visualstudio.com/app/register and fill in the registration form.

Key fields:

  • Company name: Your organization or company name
  • Application name: A descriptive name users will see during authorization
  • Application website: Your application's homepage URL
  • Authorization callback URL: The exact URL Azure DevOps will redirect to after authorization (e.g., https://yourapp.com/auth/callback)
  • Authorized scopes: The permissions your application requires

After registration, you receive two critical values:

  • App ID: A unique identifier for your application
  • Client Secret: A secret value used for token exchange (treat this like a password)

Store these securely. The Client Secret is shown only once during registration. If you lose it, you must generate a new one.

App ID:       A1B2C3D4-E5F6-7890-ABCD-EF1234567890
Client Secret: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...
Callback URL:  https://yourapp.com/auth/callback

Scopes and the Permissions Model

Azure DevOps OAuth scopes use a colon-delimited format that is different from Azure AD scopes. Each scope grants access to a specific area of the API.

Scope Description
vso.build Read access to build artifacts and definitions
vso.build_execute Read and execute builds
vso.code Read access to source code and metadata
vso.code_write Read and write access to source code
vso.code_manage Full access to source code, including delete
vso.project Read access to projects and teams
vso.project_manage Create, read, update, and delete projects
vso.work Read access to work items and queries
vso.work_write Read and create work items
vso.work_full Full access to work items
vso.release Read access to release artifacts
vso.release_execute Read and execute releases
vso.profile Read access to the user's profile
vso.identity Read access to identities and groups

When requesting multiple scopes, separate them with spaces in the authorization URL. Request the minimum scopes your application needs — users are more likely to approve a limited scope request, and it follows the principle of least privilege.

// Multiple scopes separated by spaces
var scopes = "vso.profile vso.work_write vso.code vso.build";

Constructing the Authorization URL

The authorization URL is where you redirect users to begin the OAuth flow. It must include several query parameters encoded correctly.

var crypto = require("crypto");

function buildAuthorizationUrl(config) {
    var state = crypto.randomBytes(32).toString("hex");

    var params = {
        client_id: config.appId,
        response_type: "Assertion",
        state: state,
        scope: config.scopes,
        redirect_uri: config.callbackUrl
    };

    var queryString = Object.keys(params).map(function(key) {
        return encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
    }).join("&");

    var authUrl = "https://app.vssps.visualstudio.com/oauth2/authorize?" + queryString;

    return {
        url: authUrl,
        state: state
    };
}

Notice that response_type is set to "Assertion", not "code" as you might expect from standard OAuth 2.0. This is specific to Azure DevOps OAuth and is a common source of confusion. The state parameter is a cryptographically random value you generate and store in the user's session. When Azure DevOps redirects back, you verify that the state matches to prevent CSRF attacks.

Token Exchange Implementation

After the user authorizes your application, Azure DevOps redirects to your callback URL with two query parameters: code (the authorization code) and state (the value you sent). You exchange the authorization code for tokens by making a POST request to the token endpoint.

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

function exchangeCodeForToken(code, config, callback) {
    var postData = 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: code,
        redirect_uri: config.callbackUrl
    });

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

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

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

    req.write(postData);
    req.end();
}

The response from the token endpoint looks like this:

{
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik1yNS1...",
    "token_type": "jwt",
    "expires_in": "3599",
    "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IkFCb0...",
    "scope": "vso.profile vso.work_write vso.code vso.build"
}

Note that token_type is "jwt", not "Bearer". When making API calls, you still use the Bearer scheme in the Authorization header despite this.

Refresh Token Handling

Access tokens expire after one hour. The refresh token flow is similar to the code exchange, but uses different grant parameters.

function refreshAccessToken(refreshToken, config, callback) {
    var postData = 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
    });

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

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

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

    req.write(postData);
    req.end();
}

A critical detail: each refresh token is single-use. When you refresh, the response includes a new refresh token. You must store this new refresh token and discard the old one. If you attempt to reuse a refresh token, the request will fail with a 400 error, and the user will need to re-authorize.

Storing Tokens Securely

Never store tokens in plain text, in client-side code, or in version control. For a production application, encrypt tokens at rest. Here is a practical approach using Node.js built-in crypto module with AES-256-GCM encryption.

var crypto = require("crypto");

var ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32-byte hex string
var IV_LENGTH = 16;
var AUTH_TAG_LENGTH = 16;

function encryptToken(plaintext) {
    var iv = crypto.randomBytes(IV_LENGTH);
    var key = Buffer.from(ENCRYPTION_KEY, "hex");
    var cipher = crypto.createCipheriv("aes-256-gcm", key, iv);

    var encrypted = cipher.update(plaintext, "utf8", "hex");
    encrypted += cipher.final("hex");
    var authTag = cipher.getAuthTag().toString("hex");

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

function decryptToken(ciphertext) {
    var parts = ciphertext.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("aes-256-gcm", key, iv);
    decipher.setAuthTag(authTag);

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

    return decrypted;
}

For database storage, create a schema that holds the encrypted tokens alongside metadata:

// PostgreSQL schema for token storage
var schema = `
CREATE TABLE oauth_tokens (
    id SERIAL PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL UNIQUE,
    azure_devops_user_id VARCHAR(255),
    display_name VARCHAR(255),
    access_token_encrypted TEXT NOT NULL,
    refresh_token_encrypted TEXT NOT NULL,
    expires_at BIGINT NOT NULL,
    scopes TEXT NOT NULL,
    organization VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_oauth_tokens_expires ON oauth_tokens(expires_at);
`;

Building a Token Manager

In production, you need a token manager that handles automatic refresh, concurrency, and error recovery. Here is a robust implementation:

var EventEmitter = require("events");

function TokenManager(config, storage) {
    this.config = config;
    this.storage = storage;
    this.refreshLocks = {};
    this.emitter = new EventEmitter();
}

TokenManager.prototype.getValidToken = function(userId, callback) {
    var self = this;

    self.storage.getTokens(userId, function(err, tokens) {
        if (err) return callback(err);
        if (!tokens) return callback(new Error("No tokens found for user"));

        var bufferMs = 5 * 60 * 1000; // 5-minute buffer before expiry
        var now = Date.now();

        if (tokens.expiresAt - now > bufferMs) {
            return callback(null, tokens.accessToken);
        }

        // Token is expired or about to expire - refresh it
        self._refreshWithLock(userId, tokens.refreshToken, callback);
    });
};

TokenManager.prototype._refreshWithLock = function(userId, refreshToken, callback) {
    var self = this;

    // Prevent concurrent refresh requests for the same user
    if (self.refreshLocks[userId]) {
        self.emitter.once("refresh:" + userId, function(err, accessToken) {
            callback(err, accessToken);
        });
        return;
    }

    self.refreshLocks[userId] = true;

    refreshAccessToken(refreshToken, self.config, function(err, newTokens) {
        delete self.refreshLocks[userId];

        if (err) {
            self.emitter.emit("refresh:" + userId, err, null);
            return callback(err);
        }

        // Store the new tokens
        self.storage.updateTokens(userId, {
            accessToken: encryptToken(newTokens.accessToken),
            refreshToken: encryptToken(newTokens.refreshToken),
            expiresAt: newTokens.expiresAt
        }, function(storeErr) {
            if (storeErr) {
                self.emitter.emit("refresh:" + userId, storeErr, null);
                return callback(storeErr);
            }

            self.emitter.emit("refresh:" + userId, null, newTokens.accessToken);
            callback(null, newTokens.accessToken);
        });
    });
};

The lock mechanism is important. Without it, if multiple API calls detect an expired token simultaneously, they would all attempt to refresh concurrently. Since refresh tokens are single-use, only the first refresh would succeed and the rest would fail, potentially invalidating the token chain entirely.

Making Authenticated API Calls

With a valid access token, you can call any Azure DevOps REST API endpoint. The token goes in the Authorization header as a Bearer token.

var https = require("https");

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

    var req = https.request(options, function(res) {
        var body = "";
        res.on("data", function(chunk) {
            body += chunk;
        });
        res.on("end", function() {
            if (res.statusCode === 200) {
                callback(null, JSON.parse(body));
            } else if (res.statusCode === 401) {
                callback(new Error("TOKEN_EXPIRED"));
            } else if (res.statusCode === 403) {
                callback(new Error("INSUFFICIENT_SCOPE"));
            } else {
                callback(new Error("API call failed: " + res.statusCode + " " + body));
            }
        });
    });

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

    req.end();
}

Multi-Tenant Application Patterns

If your application serves multiple Azure DevOps organizations, you need to handle organization discovery. After authentication, query the user's profile and their accessible organizations.

function getUserProfile(accessToken, callback) {
    var options = {
        hostname: "app.vssps.visualstudio.com",
        path: "/_apis/profile/profiles/me?api-version=6.0",
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "application/json"
        }
    };

    var req = https.request(options, function(res) {
        var body = "";
        res.on("data", function(chunk) {
            body += chunk;
        });
        res.on("end", function() {
            if (res.statusCode === 200) {
                callback(null, JSON.parse(body));
            } else {
                callback(new Error("Profile fetch failed: " + res.statusCode));
            }
        });
    });

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

function getUserOrganizations(accessToken, memberId, callback) {
    var options = {
        hostname: "app.vssps.visualstudio.com",
        path: "/_apis/accounts?memberId=" + memberId + "&api-version=6.0",
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "application/json"
        }
    };

    var req = https.request(options, function(res) {
        var body = "";
        res.on("data", function(chunk) {
            body += chunk;
        });
        res.on("end", function() {
            if (res.statusCode === 200) {
                var data = JSON.parse(body);
                var orgs = data.value.map(function(account) {
                    return {
                        id: account.accountId,
                        name: account.accountName,
                        uri: account.accountUri
                    };
                });
                callback(null, orgs);
            } else {
                callback(new Error("Organizations fetch failed: " + res.statusCode));
            }
        });
    });

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

The typical multi-tenant flow is: authenticate the user, fetch their profile, discover their organizations, let them pick which organization to connect, and then store the organization association alongside their tokens.

Complete Working Example

Here is a full Express application implementing the Azure DevOps OAuth flow with session management, token storage, and API integration. This is production-grade structure — you would add proper error handling, logging, and a real database in a real deployment.

// app.js - Azure DevOps OAuth Application
var express = require("express");
var session = require("express-session");
var crypto = require("crypto");
var https = require("https");
var querystring = require("querystring");

var app = express();

// --- Configuration ---
var config = {
    appId: 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.profile vso.work_write vso.code vso.build vso.project",
    encryptionKey: process.env.TOKEN_ENCRYPTION_KEY // 64-char hex string (32 bytes)
};

// --- Session middleware ---
app.use(session({
    secret: process.env.SESSION_SECRET || "change-this-in-production",
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: process.env.NODE_ENV === "production",
        httpOnly: true,
        maxAge: 24 * 60 * 60 * 1000 // 24 hours
    }
}));

app.use(express.json());

// --- In-memory token store (use a database in production) ---
var tokenStore = {};

// --- Encryption helpers ---
function encrypt(text) {
    var iv = crypto.randomBytes(16);
    var key = Buffer.from(config.encryptionKey, "hex");
    var cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
    var encrypted = cipher.update(text, "utf8", "hex") + cipher.final("hex");
    var tag = cipher.getAuthTag().toString("hex");
    return iv.toString("hex") + ":" + tag + ":" + encrypted;
}

function decrypt(ciphertext) {
    var parts = ciphertext.split(":");
    var iv = Buffer.from(parts[0], "hex");
    var tag = Buffer.from(parts[1], "hex");
    var key = Buffer.from(config.encryptionKey, "hex");
    var decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
    decipher.setAuthTag(tag);
    return decipher.update(parts[2], "hex", "utf8") + decipher.final("utf8");
}

// --- HTTP helper ---
function httpsPost(hostname, path, data, callback) {
    var postData = querystring.stringify(data);
    var options = {
        hostname: hostname,
        path: path,
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "Content-Length": Buffer.byteLength(postData)
        }
    };

    var req = https.request(options, function(res) {
        var body = "";
        res.on("data", function(chunk) { body += chunk; });
        res.on("end", function() {
            callback(null, res.statusCode, body);
        });
    });
    req.on("error", callback);
    req.write(postData);
    req.end();
}

function httpsGet(hostname, path, accessToken, callback) {
    var options = {
        hostname: hostname,
        path: path,
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "application/json"
        }
    };

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

// --- Token refresh ---
function refreshTokens(userId, callback) {
    var stored = tokenStore[userId];
    if (!stored) return callback(new Error("No tokens for user"));

    var currentRefreshToken = decrypt(stored.refreshTokenEncrypted);

    httpsPost("app.vssps.visualstudio.com", "/oauth2/token", {
        client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        client_assertion: config.clientSecret,
        grant_type: "refresh_token",
        assertion: currentRefreshToken,
        redirect_uri: config.callbackUrl
    }, function(err, statusCode, body) {
        if (err) return callback(err);
        if (statusCode !== 200) {
            return callback(new Error("Refresh failed (" + statusCode + "): " + body));
        }

        var data = JSON.parse(body);
        tokenStore[userId] = {
            accessTokenEncrypted: encrypt(data.access_token),
            refreshTokenEncrypted: encrypt(data.refresh_token),
            expiresAt: Date.now() + (parseInt(data.expires_in) * 1000),
            scopes: data.scope
        };
        callback(null, data.access_token);
    });
}

// --- Get valid access token ---
function getAccessToken(userId, callback) {
    var stored = tokenStore[userId];
    if (!stored) return callback(new Error("Not authenticated"));

    var fiveMinutes = 5 * 60 * 1000;
    if (stored.expiresAt - Date.now() > fiveMinutes) {
        return callback(null, decrypt(stored.accessTokenEncrypted));
    }

    refreshTokens(userId, callback);
}

// --- Middleware: require auth ---
function requireAuth(req, res, next) {
    if (!req.session.userId || !tokenStore[req.session.userId]) {
        return res.status(401).json({ error: "Not authenticated. Visit /auth/login to connect." });
    }
    next();
}

// --- Routes ---

// Home page
app.get("/", function(req, res) {
    var authenticated = !!(req.session.userId && tokenStore[req.session.userId]);
    res.json({
        message: "Azure DevOps OAuth Demo",
        authenticated: authenticated,
        endpoints: {
            login: "/auth/login",
            profile: "/api/profile",
            projects: "/api/projects",
            workItems: "/api/work-items/:organization/:project"
        }
    });
});

// Initiate OAuth flow
app.get("/auth/login", function(req, res) {
    var state = crypto.randomBytes(32).toString("hex");
    req.session.oauthState = state;

    var params = querystring.stringify({
        client_id: config.appId,
        response_type: "Assertion",
        state: state,
        scope: config.scopes,
        redirect_uri: config.callbackUrl
    });

    res.redirect("https://app.vssps.visualstudio.com/oauth2/authorize?" + params);
});

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

    // Verify state parameter
    if (!state || state !== req.session.oauthState) {
        return res.status(400).json({ error: "Invalid state parameter. Possible CSRF attack." });
    }
    delete req.session.oauthState;

    if (!code) {
        return res.status(400).json({ error: "No authorization code received." });
    }

    // Exchange code for tokens
    httpsPost("app.vssps.visualstudio.com", "/oauth2/token", {
        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: code,
        redirect_uri: config.callbackUrl
    }, function(err, statusCode, body) {
        if (err) {
            return res.status(500).json({ error: "Token exchange error: " + err.message });
        }

        if (statusCode !== 200) {
            return res.status(500).json({ error: "Token exchange failed", details: body });
        }

        var data = JSON.parse(body);

        // Fetch user profile to get a stable user ID
        httpsGet("app.vssps.visualstudio.com", "/_apis/profile/profiles/me?api-version=6.0",
            data.access_token, function(profileErr, profileStatus, profileBody) {
                if (profileErr || profileStatus !== 200) {
                    return res.status(500).json({ error: "Failed to fetch user profile" });
                }

                var profile = JSON.parse(profileBody);
                var userId = profile.id;

                // Store encrypted tokens
                tokenStore[userId] = {
                    accessTokenEncrypted: encrypt(data.access_token),
                    refreshTokenEncrypted: encrypt(data.refresh_token),
                    expiresAt: Date.now() + (parseInt(data.expires_in) * 1000),
                    scopes: data.scope,
                    displayName: profile.displayName,
                    emailAddress: profile.emailAddress
                };

                req.session.userId = userId;
                res.redirect("/api/profile");
            }
        );
    });
});

// Get user profile and organizations
app.get("/api/profile", requireAuth, function(req, res) {
    var userId = req.session.userId;
    var stored = tokenStore[userId];

    getAccessToken(userId, function(err, accessToken) {
        if (err) return res.status(401).json({ error: err.message });

        httpsGet("app.vssps.visualstudio.com",
            "/_apis/accounts?memberId=" + userId + "&api-version=6.0",
            accessToken, function(orgErr, orgStatus, orgBody) {
                var organizations = [];
                if (!orgErr && orgStatus === 200) {
                    var orgData = JSON.parse(orgBody);
                    organizations = orgData.value.map(function(a) {
                        return { id: a.accountId, name: a.accountName };
                    });
                }

                res.json({
                    user: {
                        id: userId,
                        displayName: stored.displayName,
                        email: stored.emailAddress
                    },
                    organizations: organizations,
                    tokenExpiresAt: new Date(stored.expiresAt).toISOString()
                });
            }
        );
    });
});

// List projects for an organization
app.get("/api/projects", requireAuth, function(req, res) {
    var organization = req.query.org;
    if (!organization) {
        return res.status(400).json({ error: "Missing 'org' query parameter" });
    }

    getAccessToken(req.session.userId, function(err, accessToken) {
        if (err) return res.status(401).json({ error: err.message });

        httpsGet("dev.azure.com",
            "/" + organization + "/_apis/projects?api-version=7.0",
            accessToken, function(apiErr, apiStatus, apiBody) {
                if (apiErr) return res.status(500).json({ error: apiErr.message });
                if (apiStatus !== 200) {
                    return res.status(apiStatus).json({ error: "API error", details: apiBody });
                }

                var data = JSON.parse(apiBody);
                var projects = data.value.map(function(p) {
                    return {
                        id: p.id,
                        name: p.name,
                        description: p.description,
                        state: p.state,
                        lastUpdateTime: p.lastUpdateTime
                    };
                });

                res.json({ count: data.count, projects: projects });
            }
        );
    });
});

// List recent work items
app.get("/api/work-items/:org/:project", requireAuth, function(req, res) {
    var organization = req.params.org;
    var project = req.params.project;

    getAccessToken(req.session.userId, function(err, accessToken) {
        if (err) return res.status(401).json({ error: err.message });

        // Use WIQL to query recent work items
        var wiql = JSON.stringify({
            query: "SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] " +
                   "FROM WorkItems " +
                   "WHERE [System.TeamProject] = '" + project + "' " +
                   "ORDER BY [System.ChangedDate] DESC"
        });

        var postOptions = {
            hostname: "dev.azure.com",
            path: "/" + organization + "/" + project + "/_apis/wit/wiql?api-version=7.0&$top=25",
            method: "POST",
            headers: {
                "Authorization": "Bearer " + accessToken,
                "Content-Type": "application/json",
                "Accept": "application/json",
                "Content-Length": Buffer.byteLength(wiql)
            }
        };

        var apiReq = https.request(postOptions, function(apiRes) {
            var body = "";
            apiRes.on("data", function(chunk) { body += chunk; });
            apiRes.on("end", function() {
                if (apiRes.statusCode !== 200) {
                    return res.status(apiRes.statusCode).json({ error: body });
                }
                var result = JSON.parse(body);
                res.json({
                    count: result.workItems ? result.workItems.length : 0,
                    workItems: result.workItems || []
                });
            });
        });

        apiReq.on("error", function(apiErr) {
            res.status(500).json({ error: apiErr.message });
        });

        apiReq.write(wiql);
        apiReq.end();
    });
});

// Logout
app.post("/auth/logout", function(req, res) {
    if (req.session.userId) {
        delete tokenStore[req.session.userId];
    }
    req.session.destroy(function() {
        res.json({ message: "Logged out successfully" });
    });
});

// --- Start server ---
var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
    console.log("Azure DevOps OAuth app running on port " + PORT);
    console.log("Visit http://localhost:" + PORT + "/auth/login to connect");
});

To run this example, create a package.json and install dependencies:

{
    "name": "azure-devops-oauth-demo",
    "version": "1.0.0",
    "description": "Azure DevOps OAuth 2.0 integration demo",
    "main": "app.js",
    "scripts": {
        "start": "node app.js"
    },
    "dependencies": {
        "express": "^4.18.2",
        "express-session": "^1.17.3"
    }
}

Set the required environment variables and start the server:

export AZURE_DEVOPS_APP_ID="your-app-id"
export AZURE_DEVOPS_CLIENT_SECRET="your-client-secret"
export AZURE_DEVOPS_CALLBACK_URL="http://localhost:3000/auth/callback"
export TOKEN_ENCRYPTION_KEY=$(openssl rand -hex 32)
export SESSION_SECRET=$(openssl rand -hex 32)

npm install
npm start
# Azure DevOps OAuth app running on port 3000
# Visit http://localhost:3000/auth/login to connect

A successful authentication flow will return profile data like:

{
    "user": {
        "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "displayName": "Shane Larson",
        "email": "[email protected]"
    },
    "organizations": [
        { "id": "org-id-1", "name": "grizzlypeaksoftware" },
        { "id": "org-id-2", "name": "client-project-org" }
    ],
    "tokenExpiresAt": "2026-02-13T15:30:00.000Z"
}

Handling Token Expiration and Renewal

Beyond the refresh flow shown above, your application needs a strategy for handling cases where the refresh token itself has expired or been revoked. A robust pattern wraps every API call with retry logic:

function callWithRetry(userId, organization, apiPath, callback) {
    getAccessToken(userId, function(err, accessToken) {
        if (err) return callback(err);

        callAzureDevOpsApi(accessToken, organization, apiPath, function(apiErr, data) {
            if (apiErr && apiErr.message === "TOKEN_EXPIRED") {
                // Force a refresh and retry once
                refreshTokens(userId, function(refreshErr, newToken) {
                    if (refreshErr) {
                        // Refresh failed - user must re-authorize
                        return callback(new Error("RE_AUTH_REQUIRED"));
                    }
                    callAzureDevOpsApi(newToken, organization, apiPath, callback);
                });
            } else {
                callback(apiErr, data);
            }
        });
    });
}

For background processes that run without user interaction (nightly sync jobs, scheduled reports), you should implement a proactive refresh strategy that renews tokens well before expiration:

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

// Refresh all tokens that expire within the next 30 minutes
// Runs every 15 minutes
var tokenRefreshJob = new CronJob("0 */15 * * * *", function() {
    var thirtyMinutes = 30 * 60 * 1000;
    var now = Date.now();

    Object.keys(tokenStore).forEach(function(userId) {
        var tokens = tokenStore[userId];
        if (tokens.expiresAt - now < thirtyMinutes) {
            refreshTokens(userId, function(err) {
                if (err) {
                    console.error("Proactive refresh failed for user " + userId + ": " + err.message);
                    // Mark user as needing re-authorization
                    tokens.needsReAuth = true;
                } else {
                    console.log("Proactively refreshed token for user " + userId);
                }
            });
        }
    });
});

tokenRefreshJob.start();

Common Issues and Troubleshooting

1. "TF400813: The user is not authorized to access this resource."

This error means the access token does not have the required scope for the API endpoint you are calling. Double-check the scopes you requested during authorization. If you need additional scopes, the user must re-authorize your application with the expanded scope list. You cannot incrementally add scopes.

HTTP 403
{
    "message": "TF400813: The user 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' is not authorized to access this resource.",
    "typeKey": "UnauthorizedRequestException"
}

2. "The provided value for the 'assertion' parameter is not valid."

This occurs when the authorization code has expired (it is valid for 15 minutes), has already been used, or the redirect_uri in the token request does not exactly match the one used in the authorization request.

HTTP 400
{
    "Error": "invalid_grant",
    "ErrorDescription": "The provided value for the 'assertion' parameter is not valid."
}

Fix: Ensure the redirect_uri is byte-for-byte identical in both the authorization URL and the token exchange request, including trailing slashes and protocol.

3. "The provided value for the 'client_assertion' parameter is not valid."

This means your Client Secret is wrong. This can happen if the secret was regenerated in the Azure DevOps app registration portal, or if there are encoding issues (invisible whitespace characters at the end of the environment variable).

HTTP 401
{
    "Error": "invalid_client",
    "ErrorDescription": "The provided value for the 'client_assertion' parameter is not valid."
}

Fix: Copy the Client Secret again from the app registration page. Verify with echo -n "$AZURE_DEVOPS_CLIENT_SECRET" | wc -c to check for hidden characters.

4. "TF400556: The state parameter does not match."

This is not an Azure DevOps error — it should be your own application's validation error. If you see a state mismatch, it could indicate a CSRF attack, a session timeout (the session storing the state expired before the user completed authorization), or a load balancer routing the callback to a different instance than the one that initiated the flow.

{
    "error": "Invalid state parameter. Possible CSRF attack."
}

Fix: Use a session store that is shared across instances (Redis, database) rather than in-memory sessions. Set reasonable session TTLs.

5. "AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application."

This error appears when your callback URL does not match what was registered. Unlike the assertion error above, this one surfaces at the authorization step (before you even get a code). Check for http vs https, trailing slashes, port numbers, and path case sensitivity.

Error: redirect_uri_mismatch
The redirect URI 'http://localhost:3000/auth/callback' does not match any registered redirect URIs.

6. Refresh token silently stops working after deployment

This happens when you deploy new code and your in-memory token store gets wiped. The old refresh token was never persisted. Always use durable storage (database, Redis) for tokens, and always encrypt them at rest.

Best Practices

  • Request minimum scopes. Only ask for the permissions your application actually needs. Users are more likely to trust and authorize an application that requests vso.work instead of vso.work_full.

  • Encrypt tokens at rest. Use AES-256-GCM or equivalent authenticated encryption. Never store access tokens or refresh tokens in plain text in your database, logs, or configuration files.

  • Implement CSRF protection. Always generate a cryptographically random state parameter, store it in the user's server-side session, and verify it matches when the callback is received. Do not skip this step.

  • Handle refresh token rotation correctly. Every refresh response contains a new refresh token. Store it immediately. The old refresh token is invalidated the moment a new one is issued. If your storage update fails after a successful refresh, you lose access and the user must re-authorize.

  • Use a token refresh lock. Prevent concurrent refresh attempts for the same user. Multiple simultaneous refreshes with the same token will result in all but the first failing, and you may lose the new refresh token from the successful response.

  • Proactively refresh tokens for background processes. Do not wait until a token is expired to refresh it. Refresh 5-15 minutes before expiration to avoid API call failures during the window between expiration detection and refresh completion.

  • Log token lifecycle events without logging token values. Log when tokens are issued, refreshed, and revoked. Log failures with error codes. Never log the actual token values, even in debug mode.

  • Implement graceful re-authorization. When a refresh token is truly dead (expired, revoked, or corrupted), your application should guide the user back through the authorization flow rather than displaying a cryptic error.

  • Use HTTPS everywhere in production. The callback URL must use HTTPS. Cookies carrying session IDs must have the secure flag set. Access tokens transmitted over HTTP can be intercepted.

  • Separate configuration from code. App ID, Client Secret, encryption keys, and callback URLs should come from environment variables or a secrets manager. Never commit these to version control.

References

Powered by Contentful