User Authentication with APS (3-Legged)
Accessing Autodesk Data as a Specific User
Chapter 3 authenticated as your application. Every API call used an app-level token. Your server saw what the application was provisioned to see. 3-legged authentication puts the user into the flow: the user logs in with their Autodesk account, and API calls are made on their behalf with their own permissions.
2-Legged vs 3-Legged
| 2-Legged (Chapter 3) | 3-Legged (This Chapter) | |
|---|---|---|
| Who authenticates | Your application | A specific user |
| Credentials used | Client ID + Secret | Client ID + Secret + User login |
| Sees | Everything your app is provisioned for | Everything that user is permitted to see |
| Token type | Application token | User access token |
| Use case | Server-to-server pipelines | User-specific data, personalised tools |
The 3-legged flow has four steps:
1. Your server redirects the user to Autodesk's login page
2. The user logs in at autodesk.com
3. Autodesk redirects back with a short-lived authorisation code
4. Your server exchanges the code for a user access tokenOnce the token is stored, any MCP tool can use it to call the Autodesk API as that user.
Credentials
The Traditional Web App you created in the Prerequisites supports both 2-legged and 3-legged OAuth, so no new application is needed. The callback URL (http://localhost:3001/auth/callback) was already configured during setup. We reuse the same APS_CLIENT_ID and APS_CLIENT_SECRET from Chapter 3 — your .env file for Node.js, your User Secrets store for .NET — for both flows.
Add 3-Legged OAuth to the APS Server
In Node.js, edit aps-server.js. In .NET, edit ApsServer.cs. Both gain a place to hold the user token, a new get_user_info tool, and two HTTP routes for the OAuth flow.
Section 1 – Capture the user access token
// After the existing environment-variable check at the top of the file:
const REDIRECT_URI = "http://localhost:3001/auth/callback";
// User access token - null until the user completes the 3-legged login
let userAccessToken = null;public sealed class UserTokenProvider
{
private const string RedirectUri = "http://localhost:3001/auth/callback";
private readonly AuthenticationClient _authClient;
private readonly string _clientId;
private readonly string _clientSecret;
private string? _userAccessToken;
public UserTokenProvider(AuthenticationClient authClient, IConfiguration config)
{
_authClient = authClient;
_clientId = config["APS_CLIENT_ID"]
?? throw new InvalidOperationException("Missing APS_CLIENT_ID in environment.");
_clientSecret = config["APS_CLIENT_SECRET"]
?? throw new InvalidOperationException("Missing APS_CLIENT_SECRET in environment.");
}
public string? CurrentToken => _userAccessToken;
public async Task ExchangeCodeAsync(string code, CancellationToken cancellationToken = default)
{
var token = await _authClient.GetThreeLeggedTokenAsync(
_clientId,
code,
RedirectUri,
clientSecret: _clientSecret);
_userAccessToken = token.AccessToken;
Console.WriteLine("User authenticated via 3-legged OAuth.");
}
}- Node.js — A module-level
userAccessTokenvariable is the cache; tools read it directly. - .NET —
UserTokenProvidermirrorsApsTokenProvider. The user token is set when the OAuth callback exchanges the auth code, and read by theget_user_infotool through DI.
Register the new provider (and HttpClient, used by get_user_info) inside Main, alongside the existing services. Node.js needs no extra wiring — the closure handles it.
builder.Services.AddHttpClient();
builder.Services.AddSingleton<UserTokenProvider>();You'll also need an extra using at the top of ApsServer.cs for the Authorization header used by the new tool:
using System.Net.Http.Headers;⚠️ Workshop limitation: the user token is cached server-wide — one user's token shared globally. This is fine for a single-user workshop, but in production you must scope tokens by session ID so each user has their own. See Production Checklist for details.
Section 2 – Tool: get_user_info
We add one new tool:
get_user_info: returns the Autodesk profile (name, email, and Autodesk ID) of the currently logged-in user. If no user has completed the OAuth flow yet, it returns a message telling the user to open the login URL in their browser.
Register the profile tool — inside createServer() after the existing tools for Node.js, or inside ApsTools for .NET:
// Tool 3: get_user_info
server.registerTool(
"get_user_info",
{
description:
"Returns the Autodesk profile of the currently logged-in user. " +
"The user must first authenticate by visiting http://localhost:3001/auth/login in their browser.",
},
async () => {
if (!userAccessToken) {
return {
content: [
{
type: "text",
text: "Not authenticated. Ask the user to open http://localhost:3001/auth/login in their browser to log in with Autodesk.",
},
],
};
}
const response = await fetch(
"https://api.userprofile.autodesk.com/userinfo",
{
headers: { Authorization: `Bearer ${userAccessToken}` },
},
);
if (!response.ok) {
return {
content: [{ type: "text", text: `Error: ${response.status}` }],
};
}
const user = await response.json();
return {
content: [
{
type: "text",
text: `Name: ${user.name}\nEmail: ${user.email}\nAutodesk ID: ${user.sub}`,
},
],
};
},
);[McpServerTool(Name = "get_user_info"),
Description("Returns the Autodesk profile of the currently logged-in user. The user must first authenticate by visiting http://localhost:3001/auth/login in their browser.")]
public static async Task<string> GetUserInfo(UserTokenProvider users, HttpClient http)
{
if (users.CurrentToken is null)
return "Not authenticated. Ask the user to open http://localhost:3001/auth/login in their browser to log in with Autodesk.";
using var request = new HttpRequestMessage(HttpMethod.Get, "https://developer.api.autodesk.com/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", users.CurrentToken);
using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
return $"Error: {(int)response.StatusCode}";
await using var stream = await response.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() : null;
var email = root.TryGetProperty("email", out var e) ? e.GetString() : null;
var sub = root.TryGetProperty("sub", out var s) ? s.GetString() : null;
return $"Name: {name}\nEmail: {email}\nAutodesk ID: {sub}";
}Note: The
/userinfoendpoint follows the OpenID Connect UserInfo standard. See the OIDC UserInfo reference for full details.
Section 3 – Add auth routes to the HTTP server
The updated server handles three routes:
/auth/login: redirects the user's browser to Autodesk's OAuth authorisation page, passing the client ID, callback URL, and requested scopes./auth/callback: receives the short-lived authorisation code from Autodesk after the user logs in, exchanges it for a user access token, and stores it./mcp: the existing MCP endpoint, unchanged.
Replace the existing HTTP server block — http.createServer(...) plus httpServer.listen(...) for Node.js, or the section after app.MapMcp("/mcp") for .NET — with one that handles all three:
const httpServer = http.createServer(async (req, res) => {
// Route 1: kick off 3-legged login
if (req.url === "/auth/login") {
const params = new URLSearchParams({
response_type: "code",
client_id: APS_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "data:read",
});
res.writeHead(302, {
Location: `https://developer.api.autodesk.com/authentication/v2/authorize?${params}`,
});
res.end();
return;
}
// Route 2: receive the OAuth callback, exchange the code for a user token
if (req.url?.startsWith("/auth/callback")) {
const url = new URL(req.url, "http://localhost:3001");
const code = url.searchParams.get("code");
if (!code) {
res.writeHead(400).end("Missing authorization code.");
return;
}
try {
const tokenData = await authClient.getThreeLeggedToken(
APS_CLIENT_ID,
code,
REDIRECT_URI,
{ clientSecret: APS_CLIENT_SECRET },
);
userAccessToken = tokenData.access_token;
console.log("User authenticated via 3-legged OAuth.");
res
.writeHead(200, { "Content-Type": "text/html" })
.end(
"<h1>Login successful!</h1><p>Close this tab and return to VS Code.</p>",
);
} catch (err) {
res.writeHead(500).end(`Token exchange failed: ${err.message}`);
}
return;
}
// Route 3: MCP endpoint
if (req.url !== "/mcp") {
res.writeHead(404).end("Not found");
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => transport.close());
const server = createServer();
await server.connect(transport);
await transport.handleRequest(req, res);
});
httpServer.listen(3001, () => {
console.log("APS MCP server running at http://localhost:3001/mcp");
console.log("Login at http://localhost:3001/auth/login");
});app.MapGet("/auth/login", (IConfiguration cfg) =>
{
var clientId = cfg["APS_CLIENT_ID"]
?? throw new InvalidOperationException("Missing APS_CLIENT_ID in environment.");
var query = new Dictionary<string, string?>
{
["response_type"] = "code",
["client_id"] = clientId,
["redirect_uri"] = "http://localhost:3001/auth/callback",
["scope"] = "data:read",
};
var url = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(
"https://developer.api.autodesk.com/authentication/v2/authorize", query);
return Results.Redirect(url);
});
app.MapGet("/auth/callback", async (string? code, UserTokenProvider users) =>
{
if (code is null)
return Results.BadRequest("Missing authorization code.");
try
{
await users.ExchangeCodeAsync(code, default);
return Results.Content(
"<h1>Login successful!</h1><p>Close this tab and return to VS Code.</p>",
"text/html");
}
catch (Exception ex)
{
return Results.Content($"Token exchange failed: {ex.Message}", "text/plain", null, 500);
}
});
Console.WriteLine("APS MCP server running at http://localhost:3001/mcp");
Console.WriteLine("Login at http://localhost:3001/auth/login");- Node.js — A single
http.createServercallback dispatches all three routes byreq.url. The MCP transport is created fresh per request, as in Chapter 3. - .NET —
app.MapGet(...)registers each route declaratively on the ASP.NET Core pipeline. The MCP endpoint mounted withapp.MapMcp("/mcp")already coexists with these routes — no manual dispatch needed.
View complete aps-server.js in Source Code → · View complete ApsServer.cs in Source Code →
Log In and Test
Start the servers
# Terminal 1 - APS server (now also handles OAuth routes)
node aps-server.js
# Terminal 2 - workshop server
node server.js# Terminal 1 - APS server (uses the StartupObject set in DevconWorkshop.csproj)
dotnet run
# Terminal 2 - workshop server (override StartupObject to run Server.cs)
dotnet run -p:StartupObject=DevconWorkshop.ServerLog in with Autodesk
Open a browser and go to:
http://localhost:3001/auth/loginYou are redirected to the Autodesk login page. Sign in with your Autodesk account. After a successful login, Autodesk calls your callback URL, your server exchanges the code for a user token, and the browser shows:
Login successful! Close this tab and return to VS Code.
Your terminal prints:
User authenticated via 3-legged OAuth.Ask VS Code Copilot
Run MCP: List Servers → Restart in the Command Palette. Then open Copilot Chat in Agent mode and ask:
Use `#get_user_info` to show my Autodesk profile.Copilot calls get_user_info on the APS server directly, which fetches the Autodesk /userinfo endpoint with the stored user token.
Expected output:
Name: Your Name
Email: you@company.com
Autodesk ID: AAAAAAAABBBBBBBB0000000What Changes in Production
The warning above about the globally cached user token is the most critical item. A production system also needs:
- Token refresh: the access token expires after 1 hour; use the
refresh_tokenreturned alongside it to request a new one without forcing a re-login - HTTPS callback URL: Autodesk requires HTTPS for production callback URLs (
https://yourdomain.com/auth/callback)