Security

OAuth Application Development for Azure DevOps

Complete guide to building OAuth applications for Azure DevOps, covering app registration, authorization flows, token management, scopes, and building secure third-party integrations with Express.js.

OAuth Application Development for Azure DevOps

Overview

OAuth is how you build third-party applications that interact with Azure DevOps on behalf of users — without asking for their password or PAT. When your application needs to read someone's work items, trigger their pipelines, or access their repositories, OAuth lets the user explicitly grant permission through a consent screen. I have built several Azure DevOps integrations that use OAuth — dashboards, Slack bots, custom portals — and the authorization flow has specific quirks that trip up developers who are used to standard OAuth 2.0 implementations.

Prerequisites

  • An Azure DevOps organization (any tier)
  • A Microsoft account or Azure AD account with access to the Azure DevOps app registration portal
  • Node.js 16 or later with Express.js for the web application
  • A publicly accessible URL for the callback endpoint (ngrok works for development)
  • Understanding of OAuth 2.0 authorization code flow fundamentals
  • HTTPS capability for production deployments (OAuth requires secure callbacks)

Registering an OAuth App in Azure DevOps

Azure DevOps OAuth apps are registered at the Visual Studio app registration portal, not the Azure Portal.

Step-by-Step Registration

  1. Navigate to https://app.vsaex.visualstudio.com/app/register
  2. Sign in with your Microsoft account
  3. Fill in the registration form:
Field Value Notes
Company name Your Company Displayed on consent screen
Application name My DevOps Dashboard Displayed on consent screen
Application website https://yourapp.com Public URL
Authorization callback URL https://yourapp.com/auth/callback Must be HTTPS in production
Authorized scopes Select required scopes See scopes section below
  1. Click Create Application
  2. Note the App ID and Client Secret — you need both

The App ID is a GUID. The Client Secret is a long string. Store both securely — the client secret is shown only once at registration.

OAuth Scopes

Azure DevOps uses its own scope format, not standard OAuth scopes:

Scope Permission Description
vso.work Work items (read) View work items and queries
vso.work_write Work items (read, write) Create and update work items
vso.build Build (read) View builds and definitions
vso.build_execute Build (read, execute) Queue builds
vso.code Code (read) Clone and read repositories
vso.code_write Code (read, write) Push to repositories
vso.code_manage Code (read, write, manage) Create branches, manage PRs
vso.project Project (read) View projects and teams
vso.profile User profile (read) Read user's profile information
vso.identity Identity (read) Read identities and groups

Request only the scopes your application needs. Users see every requested scope on the consent screen.

OAuth 2.0 Authorization Flow

Azure DevOps uses the standard authorization code grant flow with a few platform-specific details.

Flow Diagram

User                    Your App                  Azure DevOps
  |                        |                           |
  |-- Click "Login" ------>|                           |
  |                        |-- Redirect to /authorize->|
  |                        |                           |
  |<--------- Consent screen displayed ----------------|
  |                        |                           |
  |-- Grant permission --->|                           |
  |                        |<-- Redirect with code ----|
  |                        |                           |
  |                        |-- POST /token (code) ---->|
  |                        |<-- Access token + refresh-|
  |                        |                           |
  |                        |-- API call with token --->|
  |                        |<-- API response ----------|
  |                        |                           |
  |<-- Show dashboard -----|                           |

Authorization URL

https://app.vssps.visualstudio.com/oauth2/authorize
  ?client_id={App ID}
  &response_type=Assertion
  &state={random state}
  &scope={scopes}
  &redirect_uri={callback URL}

Note: Azure DevOps uses response_type=Assertion, not the standard response_type=code. This is a platform-specific requirement.

Token Exchange

After the user authorizes, Azure DevOps redirects to your callback with a code parameter. Exchange it for tokens:

POST https://app.vssps.visualstudio.com/oauth2/token

Content-Type: application/x-www-form-urlencoded

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}

The response contains an access token and refresh token:

{
  "access_token": "eyJ0eXAi...",
  "token_type": "jwt",
  "expires_in": "3599",
  "refresh_token": "eyJ0eXAi..."
}

Access tokens expire in 1 hour. Use the refresh token to get new access tokens without re-prompting the user.

Building the Express.js OAuth Application

Project Setup

mkdir devops-oauth-app
cd devops-oauth-app
npm init -y
npm install express express-session uuid

Application Code

// app.js
var express = require("express");
var session = require("express-session");
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.CALLBACK_URL || "http://localhost:3000/auth/callback";
var SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex");
var SCOPES = "vso.work vso.build vso.code vso.project vso.profile";

app.use(session({
    secret: SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }
}));

app.use(express.json());

// Home page
app.get("/", function(req, res) {
    if (req.session.tokens) {
        res.send(
            "<h1>Azure DevOps Dashboard</h1>" +
            "<p>Logged in as: " + (req.session.profile ? req.session.profile.displayName : "unknown") + "</p>" +
            "<p><a href=\"/dashboard\">View Dashboard</a></p>" +
            "<p><a href=\"/auth/logout\">Logout</a></p>"
        );
    } else {
        res.send(
            "<h1>Azure DevOps Dashboard</h1>" +
            "<p><a href=\"/auth/login\">Login with Azure DevOps</a></p>"
        );
    }
});

// Step 1: Redirect to Azure DevOps authorization
app.get("/auth/login", function(req, res) {
    var state = crypto.randomBytes(16).toString("hex");
    req.session.oauthState = state;

    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);
});

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

    // Validate state to prevent CSRF
    if (state !== req.session.oauthState) {
        console.error("State mismatch: expected " + req.session.oauthState + ", got " + state);
        return res.status(403).send("Invalid state parameter. Possible CSRF attack.");
    }

    if (!code) {
        console.error("No authorization code received");
        return res.status(400).send("Authorization failed. No code received.");
    }

    // Step 3: Exchange code for tokens
    exchangeCodeForTokens(code, function(err, tokens) {
        if (err) {
            console.error("Token exchange failed:", err.message);
            return res.status(500).send("Authentication failed: " + err.message);
        }

        req.session.tokens = tokens;
        req.session.tokenExpiry = Date.now() + (parseInt(tokens.expires_in, 10) * 1000);

        // Fetch user profile
        fetchProfile(tokens.access_token, function(err2, profile) {
            if (err2) {
                console.error("Profile fetch failed:", err2.message);
            } else {
                req.session.profile = profile;
                console.log("User authenticated: " + profile.displayName + " (" + profile.emailAddress + ")");
            }

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

// Token exchange helper
function exchangeCodeForTokens(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) {
                try { callback(null, JSON.parse(data)); }
                catch (e) { callback(new Error("Failed to parse token response")); }
            } else {
                callback(new Error("Token exchange failed (" + res.statusCode + "): " + data));
            }
        });
    });

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

// Token refresh helper
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) {
                try { callback(null, JSON.parse(data)); }
                catch (e) { callback(new Error("Failed to parse refresh response")); }
            } else {
                callback(new Error("Token refresh failed (" + res.statusCode + "): " + data));
            }
        });
    });

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

// Middleware: ensure authenticated and refresh token if needed
function requireAuth(req, res, next) {
    if (!req.session.tokens) {
        return res.redirect("/auth/login");
    }

    // Check if token is expired or expiring within 5 minutes
    if (req.session.tokenExpiry && Date.now() > req.session.tokenExpiry - 300000) {
        console.log("Access token expiring, refreshing...");

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

            req.session.tokens = newTokens;
            req.session.tokenExpiry = Date.now() + (parseInt(newTokens.expires_in, 10) * 1000);
            console.log("Token refreshed successfully");
            next();
        });
    } else {
        next();
    }
}

// Azure DevOps API helper
function devopsAPI(accessToken, org, path, callback) {
    var options = {
        hostname: "dev.azure.com",
        path: "/" + org + path,
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "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 === 200) {
                try { callback(null, JSON.parse(data)); }
                catch (e) { callback(new Error("Parse error")); }
            } else {
                callback(new Error("API error " + res.statusCode + ": " + data.substring(0, 200)));
            }
        });
    });

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

// Fetch user profile
function fetchProfile(accessToken, callback) {
    var options = {
        hostname: "app.vssps.visualstudio.com",
        path: "/_apis/profile/profiles/me?api-version=7.1",
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "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 === 200) {
                callback(null, JSON.parse(data));
            } else {
                callback(new Error("Profile error: " + res.statusCode));
            }
        });
    });

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

// Fetch user's organizations
function fetchOrganizations(accessToken, memberId, callback) {
    var options = {
        hostname: "app.vssps.visualstudio.com",
        path: "/_apis/accounts?memberId=" + memberId + "&api-version=7.1",
        method: "GET",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Accept": "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 === 200) {
                callback(null, JSON.parse(data));
            } else {
                callback(new Error("Accounts error: " + res.statusCode));
            }
        });
    });

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

// Dashboard route
app.get("/dashboard", requireAuth, function(req, res) {
    var accessToken = req.session.tokens.access_token;
    var profile = req.session.profile;

    // Fetch organizations
    fetchOrganizations(accessToken, profile.id, function(err, orgsData) {
        if (err) {
            return res.status(500).send("Failed to fetch organizations: " + err.message);
        }

        var orgs = orgsData.value || [];
        var html = "<h1>Dashboard</h1>";
        html += "<p>Welcome, " + profile.displayName + "</p>";
        html += "<h2>Your Organizations</h2>";
        html += "<ul>";

        orgs.forEach(function(org) {
            html += "<li><a href=\"/org/" + org.accountName + "/projects\">" + org.accountName + "</a></li>";
        });

        html += "</ul>";
        html += "<p><a href=\"/\">Home</a> | <a href=\"/auth/logout\">Logout</a></p>";

        res.send(html);
    });
});

// List projects for an organization
app.get("/org/:org/projects", requireAuth, function(req, res) {
    var org = req.params.org;
    var accessToken = req.session.tokens.access_token;

    devopsAPI(accessToken, org, "/_apis/projects?api-version=7.1", function(err, data) {
        if (err) {
            return res.status(500).send("Failed to fetch projects: " + err.message);
        }

        var projects = data.value || [];
        var html = "<h1>Projects in " + org + "</h1><ul>";

        projects.forEach(function(p) {
            html += "<li><strong>" + p.name + "</strong>";
            html += " — <a href=\"/org/" + org + "/project/" + p.name + "/workitems\">Work Items</a>";
            html += " | <a href=\"/org/" + org + "/project/" + p.name + "/builds\">Builds</a>";
            html += " | <a href=\"/org/" + org + "/project/" + p.name + "/repos\">Repos</a>";
            html += "</li>";
        });

        html += "</ul>";
        html += "<p><a href=\"/dashboard\">Back to Dashboard</a></p>";
        res.send(html);
    });
});

// List recent work items
app.get("/org/:org/project/:project/workitems", requireAuth, function(req, res) {
    var org = req.params.org;
    var project = req.params.project;
    var accessToken = req.session.tokens.access_token;

    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 options = {
        hostname: "dev.azure.com",
        path: "/" + org + "/" + project + "/_apis/wit/wiql?api-version=7.1&$top=20",
        method: "POST",
        headers: {
            "Authorization": "Bearer " + accessToken,
            "Content-Type": "application/json",
            "Content-Length": Buffer.byteLength(wiql)
        }
    };

    var apiReq = https.request(options, function(apiRes) {
        var data = "";
        apiRes.on("data", function(chunk) { data += chunk; });
        apiRes.on("end", function() {
            if (apiRes.statusCode !== 200) {
                return res.status(500).send("WIQL query failed: " + data.substring(0, 300));
            }

            var result = JSON.parse(data);
            var workItems = result.workItems || [];

            if (workItems.length === 0) {
                return res.send("<h1>Work Items</h1><p>No work items found.</p><p><a href=\"/org/" + org + "/projects\">Back</a></p>");
            }

            // Fetch work item details
            var ids = workItems.slice(0, 20).map(function(wi) { return wi.id; }).join(",");

            devopsAPI(accessToken, org, "/" + project + "/_apis/wit/workitems?ids=" + ids + "&fields=System.Title,System.State,System.AssignedTo&api-version=7.1", function(err2, wiData) {
                if (err2) {
                    return res.status(500).send("Work item fetch failed: " + err2.message);
                }

                var items = wiData.value || [];
                var html = "<h1>Recent Work Items — " + project + "</h1>";
                html += "<table border='1' cellpadding='5'><tr><th>ID</th><th>Title</th><th>State</th><th>Assigned To</th></tr>";

                items.forEach(function(item) {
                    var fields = item.fields;
                    html += "<tr>";
                    html += "<td>" + item.id + "</td>";
                    html += "<td>" + (fields["System.Title"] || "") + "</td>";
                    html += "<td>" + (fields["System.State"] || "") + "</td>";
                    html += "<td>" + (fields["System.AssignedTo"] ? fields["System.AssignedTo"].displayName : "Unassigned") + "</td>";
                    html += "</tr>";
                });

                html += "</table>";
                html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
                res.send(html);
            });
        });
    });

    apiReq.on("error", function(err) { res.status(500).send("Request error: " + err.message); });
    apiReq.write(wiql);
    apiReq.end();
});

// List recent builds
app.get("/org/:org/project/:project/builds", requireAuth, function(req, res) {
    var org = req.params.org;
    var project = req.params.project;
    var accessToken = req.session.tokens.access_token;

    devopsAPI(accessToken, org, "/" + project + "/_apis/build/builds?$top=20&api-version=7.1", function(err, data) {
        if (err) {
            return res.status(500).send("Build fetch failed: " + err.message);
        }

        var builds = data.value || [];
        var html = "<h1>Recent Builds — " + project + "</h1>";
        html += "<table border='1' cellpadding='5'><tr><th>#</th><th>Definition</th><th>Status</th><th>Result</th><th>Branch</th><th>Finished</th></tr>";

        builds.forEach(function(build) {
            html += "<tr>";
            html += "<td>" + build.buildNumber + "</td>";
            html += "<td>" + build.definition.name + "</td>";
            html += "<td>" + build.status + "</td>";
            html += "<td>" + (build.result || "—") + "</td>";
            html += "<td>" + (build.sourceBranch || "").replace("refs/heads/", "") + "</td>";
            html += "<td>" + (build.finishTime ? new Date(build.finishTime).toLocaleString() : "running") + "</td>";
            html += "</tr>";
        });

        html += "</table>";
        html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
        res.send(html);
    });
});

// List repositories
app.get("/org/:org/project/:project/repos", requireAuth, function(req, res) {
    var org = req.params.org;
    var project = req.params.project;
    var accessToken = req.session.tokens.access_token;

    devopsAPI(accessToken, org, "/" + project + "/_apis/git/repositories?api-version=7.1", function(err, data) {
        if (err) {
            return res.status(500).send("Repo fetch failed: " + err.message);
        }

        var repos = data.value || [];
        var html = "<h1>Repositories — " + project + "</h1><ul>";

        repos.forEach(function(repo) {
            html += "<li><strong>" + repo.name + "</strong> — " + (repo.size / 1024).toFixed(0) + " KB";
            html += " — Default branch: " + (repo.defaultBranch || "none").replace("refs/heads/", "");
            html += "</li>";
        });

        html += "</ul>";
        html += "<p><a href=\"/org/" + org + "/projects\">Back to Projects</a></p>";
        res.send(html);
    });
});

// Logout
app.get("/auth/logout", function(req, res) {
    req.session.destroy(function(err) {
        if (err) { console.error("Session destroy error:", err); }
        res.redirect("/");
    });
});

// Start server
var PORT = parseInt(process.env.PORT, 10) || 3000;
app.listen(PORT, function() {
    console.log("Azure DevOps OAuth app running on http://localhost:" + PORT);
    console.log("Callback URL: " + CALLBACK_URL);
    console.log("Client ID: " + CLIENT_ID);
});

Running the Application

# Set environment variables
export AZURE_DEVOPS_APP_ID="your-app-id-guid"
export AZURE_DEVOPS_CLIENT_SECRET="your-client-secret"
export CALLBACK_URL="http://localhost:3000/auth/callback"

# Start the app
node app.js

Output:

Azure DevOps OAuth app running on http://localhost:3000
Callback URL: http://localhost:3000/auth/callback
Client ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee

For development with ngrok:

# In terminal 1
node app.js

# In terminal 2
ngrok http 3000

# Update CALLBACK_URL to the ngrok HTTPS URL
# Update the callback URL in the app registration as well

Token Storage and Security

Never store OAuth tokens in plain text. For production applications, encrypt tokens at rest.

// lib/token-store.js
var crypto = require("crypto");
var fs = require("fs");

var ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32 bytes hex
var IV_LENGTH = 16;
var ALGORITHM = "aes-256-cbc";

function encrypt(text) {
    var iv = crypto.randomBytes(IV_LENGTH);
    var cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
    var encrypted = cipher.update(text, "utf8");
    encrypted = Buffer.concat([encrypted, cipher.final()]);
    return iv.toString("hex") + ":" + encrypted.toString("hex");
}

function decrypt(text) {
    var parts = text.split(":");
    var iv = Buffer.from(parts[0], "hex");
    var encrypted = Buffer.from(parts[1], "hex");
    var decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
    var decrypted = decipher.update(encrypted);
    decrypted = Buffer.concat([decrypted, decipher.final()]);
    return decrypted.toString("utf8");
}

function storeTokens(userId, tokens) {
    var encrypted = encrypt(JSON.stringify(tokens));
    // In production, store in a database — this uses filesystem for demo
    var tokenDir = "./data/tokens";
    if (!fs.existsSync(tokenDir)) { fs.mkdirSync(tokenDir, { recursive: true }); }
    fs.writeFileSync(tokenDir + "/" + userId + ".enc", encrypted, "utf8");
}

function loadTokens(userId) {
    var tokenFile = "./data/tokens/" + userId + ".enc";
    if (!fs.existsSync(tokenFile)) { return null; }
    var encrypted = fs.readFileSync(tokenFile, "utf8");
    return JSON.parse(decrypt(encrypted));
}

function deleteTokens(userId) {
    var tokenFile = "./data/tokens/" + userId + ".enc";
    if (fs.existsSync(tokenFile)) { fs.unlinkSync(tokenFile); }
}

module.exports = {
    storeTokens: storeTokens,
    loadTokens: loadTokens,
    deleteTokens: deleteTokens
};

Token Revocation

When a user disconnects your app, revoke the token:

// lib/revoke-token.js
var https = require("https");
var querystring = require("querystring");

function revokeToken(accessToken, callback) {
    var body = querystring.stringify({
        token: accessToken,
        token_type_hint: "access_token"
    });

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

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

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

module.exports = { revokeToken: revokeToken };

Common Issues and Troubleshooting

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

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

The callback URL in your authorization request must match the one registered exactly — same protocol (http vs https), same host, same path, same port. Trailing slashes matter. If you registered https://yourapp.com/auth/callback but your code sends https://yourapp.com/auth/callback/, it will fail. Update either the registration or the code to match.

Token exchange returns "invalid_grant"

{"error":"invalid_grant","error_description":"The provided authorization code is invalid or has expired."}

Authorization codes expire in 15 minutes. If the user delays or your callback is slow, the code expires. Also, codes can only be used once — if you accidentally replay the callback (browser refresh), the second attempt fails. Store tokens in the session immediately and redirect away from the callback URL.

"The access token is not authorized for the requested scope"

HTTP 403: {"message":"The access token is not authorized for the requested scope."}

Your app registration does not include the scope needed for the API call. If you registered with only vso.work but try to access builds, you get a 403. Update the app registration to include the missing scope, then have users re-authorize to grant the new permission.

Refresh token returns "invalid_client"

{"error":"invalid_client","error_description":"The client assertion is invalid."}

The client_assertion (your client secret) is wrong or has been rotated. Client secrets can expire if you set an expiration during registration. Regenerate the secret and update your application configuration. Also verify you are using client_assertion not client_secret — Azure DevOps uses JWT bearer assertion format.

"User did not authorize the request" after consent screen

The user clicked "Deny" on the consent screen. Your callback receives an error parameter instead of a code. Handle this gracefully:

app.get("/auth/callback", function(req, res) {
    if (req.query.error) {
        console.log("User denied authorization:", req.query.error_description);
        return res.send("Authorization was denied. <a href='/'>Try again</a>");
    }
    // ... normal flow
});

Best Practices

  • Always validate the state parameter. Generate a random state value before redirecting to the authorization URL. Verify it matches when the callback is invoked. This prevents CSRF attacks where an attacker tricks a user into authorizing your app with the attacker's account.

  • Store refresh tokens encrypted, never in plain text. Refresh tokens are long-lived and grant ongoing access. Encrypt them with AES-256 using a key stored in environment variables or Key Vault.

  • Refresh tokens proactively, not reactively. Check the token expiry before every API call. If the token expires within 5 minutes, refresh it before the call. This avoids failed requests and retry logic.

  • Request the minimum scopes your application needs. Users see every scope on the consent screen. Requesting vso.full_access when you only need vso.work erodes trust and violates the principle of least privilege.

  • Implement a logout endpoint that revokes tokens. When users disconnect your app, revoke both the access token and refresh token. Do not just delete the session — that leaves valid tokens in the wild.

  • Use HTTPS for all callback URLs in production. Azure DevOps requires HTTPS for registered callback URLs in production. HTTP is only allowed for localhost during development.

  • Handle token refresh failures by re-authenticating. Refresh tokens can be revoked by the user or expire after extended inactivity. When refresh fails, redirect the user to the login flow instead of showing an error page.

References

Powered by Contentful