Skip to content

Commit a104682

Browse files
authored
feat: Enable Personal Access Token (PAT) authentication support (#1149)
This pull request adds support for authenticating with Azure DevOps using a Personal Access Token (PAT), in addition to the existing authentication methods. The implementation includes a new authentication type, proper handling of PAT tokens, and integration with the Azure DevOps SDK. ## GitHub issue number #1146 ## **Associated Risks** N/A ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** manual testing on full scoped and partial scoped pats. as well as expired pats. updated tests
1 parent 236ecb0 commit a104682

File tree

3 files changed

+159
-5
lines changed

3 files changed

+159
-5
lines changed

src/auth.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ class OAuthAuthenticator {
7878
function createAuthenticator(type: string, tenantId?: string): () => Promise<string> {
7979
logger.debug(`Creating authenticator of type '${type}' with tenantId='${tenantId ?? "undefined"}'`);
8080
switch (type) {
81+
case "pat":
82+
logger.debug(`Authenticator: Using PAT authentication (PERSONAL_ACCESS_TOKEN)`);
83+
return async () => {
84+
logger.debug(`${type}: Reading token from PERSONAL_ACCESS_TOKEN environment variable`);
85+
const b64Pat = process.env["PERSONAL_ACCESS_TOKEN"];
86+
if (!b64Pat) {
87+
logger.error(`${type}: PERSONAL_ACCESS_TOKEN environment variable is not set or empty`);
88+
throw new Error("Environment variable 'PERSONAL_ACCESS_TOKEN' is not set or empty. Please set it with a valid base64-encoded Azure DevOps Personal Access Token.");
89+
}
90+
// Return base64 value as-is — caller uses it directly as the Basic auth credential
91+
logger.debug(`${type}: Successfully retrieved PAT from environment variable`);
92+
return b64Pat;
93+
};
94+
8195
case "envvar":
8296
logger.debug(`Authenticator: Using environment variable authentication (ADO_MCP_AUTH_TOKEN)`);
8397
// Read token from fixed environment variable

src/index.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8-
import { getBearerHandler, WebApi } from "azure-devops-node-api";
8+
import { getBearerHandler, getPersonalAccessTokenHandler, WebApi } from "azure-devops-node-api";
99
import yargs from "yargs";
1010
import { hideBin } from "yargs/helpers";
1111

@@ -47,7 +47,7 @@ const argv = yargs(hideBin(process.argv))
4747
alias: "a",
4848
describe: "Type of authentication to use",
4949
type: "string",
50-
choices: ["interactive", "azcli", "env", "envvar"],
50+
choices: ["interactive", "azcli", "env", "envvar", "pat"],
5151
default: defaultAuthenticationType,
5252
})
5353
.option("tenant", {
@@ -64,10 +64,12 @@ const orgUrl = "https://dev.azure.com/" + orgName;
6464
const domainsManager = new DomainsManager(argv.domains);
6565
export const enabledDomains = domainsManager.getEnabledDomains();
6666

67-
function getAzureDevOpsClient(getAzureDevOpsToken: () => Promise<string>, userAgentComposer: UserAgentComposer): () => Promise<WebApi> {
67+
function getAzureDevOpsClient(getAzureDevOpsToken: () => Promise<string>, userAgentComposer: UserAgentComposer, authType: string): () => Promise<WebApi> {
6868
return async () => {
6969
const accessToken = await getAzureDevOpsToken();
70-
const authHandler = getBearerHandler(accessToken);
70+
// For pat, accessToken is base64("{email}:{token}"). Decode to extract the token part,
71+
// since getPersonalAccessTokenHandler prepends ":" internally and just needs the raw token.
72+
const authHandler = authType === "pat" ? getPersonalAccessTokenHandler(Buffer.from(accessToken, "base64").toString("utf8").split(":").slice(1).join(":")) : getBearerHandler(accessToken);
7173
const connection = new WebApi(orgUrl, authHandler, undefined, {
7274
productName: "AzureDevOps.MCP",
7375
productVersion: packageVersion,
@@ -106,10 +108,27 @@ async function main() {
106108
const tenantId = (await getOrgTenant(orgName)) ?? argv.tenant;
107109
const authenticator = createAuthenticator(argv.authentication, tenantId);
108110

111+
if (argv.authentication === "pat") {
112+
const basicValue = await authenticator();
113+
// basicValue is already base64("{email}:{token}") — use it directly in the Authorization header
114+
const _originalFetch = globalThis.fetch;
115+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
116+
if (init?.headers) {
117+
const headers = new Headers(init.headers as HeadersInit);
118+
if (headers.get("Authorization")?.startsWith("Bearer ")) {
119+
headers.set("Authorization", `Basic ${basicValue}`);
120+
init = { ...init, headers };
121+
}
122+
}
123+
return _originalFetch(input, init);
124+
};
125+
logger.debug("PAT mode: global fetch interceptor installed to rewrite Bearer -> Basic auth headers");
126+
}
127+
109128
// removing prompts untill further notice
110129
// configurePrompts(server);
111130

112-
configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer), () => userAgentComposer.userAgent, enabledDomains);
131+
configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer, argv.authentication), () => userAgentComposer.userAgent, enabledDomains);
113132

114133
const transport = new StdioServerTransport();
115134
await server.connect(transport);

test/src/pat-auth.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
5+
import { jest } from "@jest/globals";
6+
7+
jest.mock("../../src/logger.js", () => ({
8+
logger: {
9+
info: jest.fn(),
10+
error: jest.fn(),
11+
warn: jest.fn(),
12+
debug: jest.fn(),
13+
},
14+
}));
15+
16+
jest.mock("@azure/identity", () => ({
17+
AzureCliCredential: jest.fn(),
18+
ChainedTokenCredential: jest.fn(),
19+
DefaultAzureCredential: jest.fn(),
20+
}));
21+
22+
jest.mock("@azure/msal-node", () => ({
23+
PublicClientApplication: jest.fn(),
24+
}));
25+
26+
jest.mock("open", () => jest.fn());
27+
28+
import { createAuthenticator } from "../../src/auth";
29+
30+
describe("PAT authentication", () => {
31+
const originalEnv = process.env;
32+
33+
beforeEach(() => {
34+
process.env = { ...originalEnv };
35+
});
36+
37+
afterEach(() => {
38+
process.env = originalEnv;
39+
});
40+
41+
describe("createAuthenticator('pat')", () => {
42+
it("should return the base64 value as-is from PERSONAL_ACCESS_TOKEN", async () => {
43+
const b64Pat = Buffer.from("user@example.com:myrawpat").toString("base64");
44+
process.env["PERSONAL_ACCESS_TOKEN"] = b64Pat;
45+
46+
const authenticator = createAuthenticator("pat");
47+
const result = await authenticator();
48+
49+
expect(result).toBe(b64Pat);
50+
});
51+
52+
it("should throw if PERSONAL_ACCESS_TOKEN is not set", async () => {
53+
delete process.env["PERSONAL_ACCESS_TOKEN"];
54+
55+
const authenticator = createAuthenticator("pat");
56+
57+
await expect(authenticator()).rejects.toThrow("Environment variable 'PERSONAL_ACCESS_TOKEN' is not set or empty");
58+
});
59+
60+
it("should throw if PERSONAL_ACCESS_TOKEN is an empty string", async () => {
61+
process.env["PERSONAL_ACCESS_TOKEN"] = "";
62+
63+
const authenticator = createAuthenticator("pat");
64+
65+
await expect(authenticator()).rejects.toThrow("Environment variable 'PERSONAL_ACCESS_TOKEN' is not set or empty");
66+
});
67+
68+
it("should return a different value each call if env var changes between calls", async () => {
69+
const b64PatA = Buffer.from("user@example.com:token-a").toString("base64");
70+
const b64PatB = Buffer.from("user@example.com:token-b").toString("base64");
71+
72+
process.env["PERSONAL_ACCESS_TOKEN"] = b64PatA;
73+
const authenticator = createAuthenticator("pat");
74+
const resultA = await authenticator();
75+
76+
process.env["PERSONAL_ACCESS_TOKEN"] = b64PatB;
77+
const resultB = await authenticator();
78+
79+
expect(resultA).toBe(b64PatA);
80+
expect(resultB).toBe(b64PatB);
81+
});
82+
});
83+
84+
describe("PAT token extraction for WebApi handler", () => {
85+
it("should correctly extract raw PAT from base64(email:pat)", () => {
86+
const email = "user@example.com";
87+
const rawPat = "myRawPatToken123";
88+
const b64 = Buffer.from(`${email}:${rawPat}`).toString("base64");
89+
90+
const decoded = Buffer.from(b64, "base64").toString("utf8");
91+
const extractedPat = decoded.split(":").slice(1).join(":");
92+
93+
expect(extractedPat).toBe(rawPat);
94+
});
95+
96+
it("should correctly extract raw PAT when PAT itself contains colons", () => {
97+
const email = "user@example.com";
98+
const rawPat = "part1:part2:part3";
99+
const b64 = Buffer.from(`${email}:${rawPat}`).toString("base64");
100+
101+
const decoded = Buffer.from(b64, "base64").toString("utf8");
102+
const extractedPat = decoded.split(":").slice(1).join(":");
103+
104+
expect(extractedPat).toBe(rawPat);
105+
});
106+
107+
it("should produce a valid Basic auth header value from base64(email:pat)", () => {
108+
const email = "user@example.com";
109+
const rawPat = "myRawPatToken123";
110+
const b64Pat = Buffer.from(`${email}:${rawPat}`).toString("base64");
111+
112+
// The fetch interceptor uses b64Pat directly as the Basic credential
113+
const authHeaderValue = `Basic ${b64Pat}`;
114+
115+
// Verify the header can be decoded back to the expected credentials
116+
const decoded = Buffer.from(b64Pat, "base64").toString("utf8");
117+
expect(decoded).toBe(`${email}:${rawPat}`);
118+
expect(authHeaderValue).toBe(`Basic ${b64Pat}`);
119+
});
120+
});
121+
});

0 commit comments

Comments
 (0)