Securing your authentication credentials is critical when working with the Azure DevOps Node API. This guide outlines best practices for managing and protecting your authentication tokens and credentials to prevent unauthorized access to your Azure DevOps resources.
⚠️ WARNING: Failing to properly secure authentication credentials can lead to unauthorized access, data breaches, and potentially significant security incidents.
The Azure DevOps Node API supports several types of authentication credentials:
Credential Type | Security Level | Usage | Storage Sensitivity |
---|---|---|---|
Personal Access Tokens (PATs) | High | Most scenarios | Highly sensitive |
Basic Auth Credentials | Medium | Development, testing | Highly sensitive |
OAuth Tokens | High | User-centric apps | Highly sensitive |
Service Principal Secrets | High | Server-to-server | Highly sensitive |
❌ Bad practice:
// Credentials hardcoded directly in source code
const pat = "abcdefghijklmnopqrstuvwxyz1234567890";
const authHandler = azdev.getPersonalAccessTokenHandler(pat);
✅ Good practice:
// Credentials from environment variables
const pat = process.env.AZURE_DEVOPS_PAT;
if (!pat) {
throw new Error("Missing AZURE_DEVOPS_PAT environment variable");
}
const authHandler = azdev.getPersonalAccessTokenHandler(pat);
Environment variables are a safer way to store credentials than hardcoding them in your source code:
# Set environment variables in bash/zsh
export AZURE_DEVOPS_PAT="your-pat-token"
export AZURE_DEVOPS_ORG="https://dev.azure.com/your-organization"
# Windows Command Prompt
set AZURE_DEVOPS_PAT=your-pat-token
set AZURE_DEVOPS_ORG=https://dev.azure.com/your-organization
# PowerShell
$env:AZURE_DEVOPS_PAT="your-pat-token"
$env:AZURE_DEVOPS_ORG="https://dev.azure.com/your-organization"
For production applications, consider using a dedicated secret management service:
import { DefaultAzureCredential } from "@azure/identity";
import { SecretClient } from "@azure/keyvault-secrets";
import * as azdev from "azure-devops-node-api";
async function getAzureDevOpsConnection() {
// Create a secret client using managed identity or service principal
const credential = new DefaultAzureCredential();
const vaultUrl = "https://your-vault.vault.azure.net/";
const secretClient = new SecretClient(vaultUrl, credential);
// Retrieve the PAT from Key Vault
const patSecret = await secretClient.getSecret("azure-devops-pat");
const orgUrlSecret = await secretClient.getSecret("azure-devops-org-url");
// Create the connection using the retrieved secrets
const authHandler = azdev.getPersonalAccessTokenHandler(patSecret.value);
return new azdev.WebApi(orgUrlSecret.value, authHandler);
}
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import * as azdev from "azure-devops-node-api";
async function getAzureDevOpsConnection() {
// Create a Secrets Manager client
const client = new SecretsManagerClient({ region: "us-east-1" });
// Retrieve the PAT from Secrets Manager
const patCommand = new GetSecretValueCommand({ SecretId: "azure-devops-pat" });
const orgUrlCommand = new GetSecretValueCommand({ SecretId: "azure-devops-org-url" });
const patResponse = await client.send(patCommand);
const orgUrlResponse = await client.send(orgUrlCommand);
// Create the connection using the retrieved secrets
const authHandler = azdev.getPersonalAccessTokenHandler(patResponse.SecretString);
return new azdev.WebApi(orgUrlResponse.SecretString, authHandler);
}
If you must use configuration files, ensure they are:
import * as fs from "fs";
import * as path from "path";
import * as azdev from "azure-devops-node-api";
// Load configuration from a file not tracked in git
function loadConfig() {
try {
const configPath = path.join(process.cwd(), '.config', 'credentials.json');
const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
throw new Error(`Failed to load config: ${error.message}`);
}
}
async function getConnection() {
const config = loadConfig();
const authHandler = azdev.getPersonalAccessTokenHandler(config.pat);
return new azdev.WebApi(config.orgUrl, authHandler);
}
Make sure to add the configuration file to .gitignore
:
# .gitignore
.config/credentials.json
When creating a PAT or configuring OAuth permissions, request only the scopes your application actually needs:
If you need to... | Request only these scopes |
---|---|
Read work items | vso.work_read |
Read and write work items | vso.work |
Read code | vso.code_read |
Read and write code | vso.code |
Read build definitions | vso.build_read |
Read and write build definitions | vso.build |
Regularly rotate your credentials to minimize the impact of potential leaks:
import * as azdev from "azure-devops-node-api";
import * as fs from "fs";
import * as path from "path";
// Function to check if token needs rotation
function checkTokenRotation() {
const configPath = path.join(process.cwd(), '.config', 'token-metadata.json');
let metadata;
try {
metadata = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (error) {
// If file doesn't exist or is invalid, create new metadata
metadata = {
created: Date.now(),
rotated: Date.now()
};
}
const daysSinceRotation = (Date.now() - metadata.rotated) / (1000 * 60 * 60 * 24);
// If token is older than 90 days, it's time to rotate
if (daysSinceRotation > 90) {
console.warn("⚠️ Your PAT is over 90 days old. Consider rotating it for security.");
// Log or notify administrator to rotate token
}
return metadata;
}
Always set expiration dates on your tokens:
Implement monitoring to detect unusual patterns in token usage:
import * as azdev from "azure-devops-node-api";
// Wrapper class with usage tracking
class AzureDevOpsClient {
private connection: azdev.WebApi;
private usageMetrics: {
lastUsed: Date;
requestCount: number;
apiCalls: Record<string, number>;
};
constructor(orgUrl: string, authHandler: azdev.IRequestHandler) {
this.connection = new azdev.WebApi(orgUrl, authHandler);
this.usageMetrics = {
lastUsed: new Date(),
requestCount: 0,
apiCalls: {}
};
}
async getGitApi() {
this.trackUsage('git');
return await this.connection.getGitApi();
}
async getBuildApi() {
this.trackUsage('build');
return await this.connection.getBuildApi();
}
async getWorkItemTrackingApi() {
this.trackUsage('workItemTracking');
return await this.connection.getWorkItemTrackingApi();
}
private trackUsage(apiName: string) {
this.usageMetrics.lastUsed = new Date();
this.usageMetrics.requestCount++;
this.usageMetrics.apiCalls[apiName] = (this.usageMetrics.apiCalls[apiName] || 0) + 1;
// You could send these metrics to a logging system
console.log(`API usage: ${apiName}, total requests: ${this.usageMetrics.requestCount}`);
// Check for unusual patterns
this.detectAnomalies();
}
private detectAnomalies() {
// Example: detect unusually high request rates
const recentRequests = this.usageMetrics.requestCount;
if (recentRequests > 1000) {
console.warn("⚠️ Unusually high number of API requests detected");
// Alert or take action
}
}
}
// OAuth best practices example (abbreviated)
app.get('/login', (req, res) => {
// Generate and store PKCE code verifier
const codeVerifier = generateRandomString(64);
// Create code challenge from verifier
const codeChallenge = base64UrlEncode(
createHash('sha256').update(codeVerifier).digest()
);
// Store in session
req.session.codeVerifier = codeVerifier;
// Generate state parameter
const state = generateRandomString(32);
req.session.oauthState = state;
// Build authorization URL with PKCE and state
const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.append('client_id', clientId);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', redirectUri);
authUrl.searchParams.append('scope', 'offline_access user.read');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('state', state);
res.redirect(authUrl.toString());
});
dotenv
for local environment configuration// Using dotenv for local development
import dotenv from 'dotenv';
import * as azdev from 'azure-devops-node-api';
// Load environment variables from .env file (in dev only)
if (process.env.NODE_ENV === 'development') {
dotenv.config();
}
const token = process.env.AZURE_DEVOPS_PAT;
const orgUrl = process.env.AZURE_DEVOPS_ORG;
const authHandler = azdev.getPersonalAccessTokenHandler(token);
const connection = new azdev.WebApi(orgUrl, authHandler);
# azure-pipelines.yml
variables:
- group: azure-devops-api-secrets # Variable group containing secrets
steps:
- task: NodeTool@0
inputs:
versionSpec: '16.x'
- script: |
npm install
npm run test
env:
AZURE_DEVOPS_PAT: $(azureDevOpsPat) # Reference to secret variable
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
env:
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
If you suspect that your credentials have been compromised:
// Example emergency revocation script
import * as azdev from 'azure-devops-node-api';
import { IRequestHandler } from 'typed-rest-client/Interfaces';
/**
* Emergency function to revoke a PAT
* Requires a separate admin-level PAT to revoke the compromised one
*/
async function emergencyRevoke(orgUrl: string, adminPat: string, compromisedPatDescriptor: string) {
try {
// Create connection with admin token
const authHandler = azdev.getPersonalAccessTokenHandler(adminPat);
const connection = new azdev.WebApi(orgUrl, authHandler);
// Get token administration API (Note: This is a simplified example)
const tokenApi = await connection.getTokenAdministrationApi();
// Revoke the compromised token
await tokenApi.revokeToken(compromisedPatDescriptor);
console.log('Token successfully revoked');
// Audit recent activity with the token
const auditApi = await connection.getAuditApi();
const auditEvents = await auditApi.queryLog({
startTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days
skipToken: null
});
console.log('Recent audit events to review:', auditEvents);
} catch (error) {
console.error('Failed to revoke token:', error);
throw error;
}
}