Skip to content

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 authenticatesYour applicationA specific user
Credentials usedClient ID + SecretClient ID + Secret + User login
SeesEverything your app is provisioned forEverything that user is permitted to see
Token typeApplication tokenUser access token
Use caseServer-to-server pipelinesUser-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 token

Once 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 and Python, 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. In Python, edit aps-server.py. All 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 ​

javascript
// 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;
csharp
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.");
    }
}
python
# After the existing environment-variable check near the top of the file:
REDIRECT_URI = "http://localhost:3001/auth/callback"

# User access token - None until the user completes the 3-legged login
_user_access_token: str | None = None
  • Node.js — A module-level userAccessToken variable is the cache; tools read it directly.
  • .NET — UserTokenProvider mirrors ApsTokenProvider. The user token is set when the OAuth callback exchanges the auth code, and read by the get_user_info tool through DI.
  • Python — A module-level _user_access_token global is the cache; the tool and callback read and write it directly (using global to assign).

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. Python needs no DI wiring either, but it does pull in a few new imports for the OAuth routes.

csharp
builder.Services.AddHttpClient();
builder.Services.AddSingleton<UserTokenProvider>();

You'll also need extra imports at the top of the file — for .NET, the Authorization header used by the new tool; for Python, the URL helper and the Starlette request/response types used by the OAuth routes (Starlette ships with the mcp package, so there is no new dependency to install):

csharp
using System.Net.Http.Headers;
python
from urllib.parse import urlencode

from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response

⚠️ 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:

javascript
// 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}`,
        },
      ],
    };
  },
);
csharp
[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}";
}
python
@mcp.tool(
    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 def get_user_info() -> str:
    if _user_access_token is None:
        return "Not authenticated. Ask the user to open http://localhost:3001/auth/login in their browser to log in with Autodesk."
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(
                "https://developer.api.autodesk.com/userinfo",
                headers={"Authorization": f"Bearer {_user_access_token}"},
            )
            resp.raise_for_status()
            user = resp.json()
    except httpx.HTTPStatusError as e:
        return f"Error: {e.response.text}"
    return f"Name: {user['name']}\nEmail: {user['email']}\nAutodesk ID: {user['sub']}"

Note: The /userinfo endpoint 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. In Python, FastMCP keeps owning the /mcp route; you just add the two auth routes with the @mcp.custom_route decorator:

javascript
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");
});
csharp
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");
python
@mcp.custom_route("/auth/login", methods=["GET"])
async def auth_login(request: Request) -> RedirectResponse:
    params = urlencode(
        {
            "response_type": "code",
            "client_id": APS_CLIENT_ID,
            "redirect_uri": REDIRECT_URI,
            "scope": "data:read",
        }
    )
    return RedirectResponse(
        f"https://developer.api.autodesk.com/authentication/v2/authorize?{params}",
        status_code=302,
    )


@mcp.custom_route("/auth/callback", methods=["GET"])
async def auth_callback(request: Request) -> Response:
    global _user_access_token
    code = request.query_params.get("code")
    if not code:
        return Response("Missing authorization code.", status_code=400)
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.post(
                "https://developer.api.autodesk.com/authentication/v2/token",
                data={
                    "grant_type": "authorization_code",
                    "code": code,
                    "redirect_uri": REDIRECT_URI,
                },
                auth=(cast(str, APS_CLIENT_ID), cast(str, APS_CLIENT_SECRET)),
            )
            resp.raise_for_status()
    except httpx.HTTPStatusError as e:
        return Response(f"Token exchange failed: {e.response.text}", status_code=500)
    _user_access_token = resp.json()["access_token"]
    print("User authenticated via 3-legged OAuth.")
    return HTMLResponse(
        "<h1>Login successful!</h1><p>Close this tab and return to VS Code.</p>"
    )
  • Node.js — A single http.createServer callback dispatches all three routes by req.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 with app.MapMcp("/mcp") already coexists with these routes — no manual dispatch needed.
  • Python — @mcp.custom_route(...) registers each route on the Starlette app FastMCP runs underneath, sitting alongside the /mcp endpoint — no manual dispatch needed. The code-for-token exchange sends the client ID and secret as HTTP Basic auth (auth=(...)), and the global _user_access_token line lets the callback write the cached token.

View complete aps-server.js in Source Code → · View complete ApsServer.cs in Source Code → · View complete aps-server.py in Source Code →

Log In and Test ​

Start the servers ​

bash
# Terminal 1 - APS server (now also handles OAuth routes)
node aps-server.js

# Terminal 2 - workshop server
node server.js
bash
# 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.Server
bash
# Terminal 1 - APS server (now also handles OAuth routes)
python aps-server.py

# Terminal 2 - workshop server
python server.py

Log in with Autodesk ​

Open a browser and go to:

http://localhost:3001/auth/login

You 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: AAAAAAAABBBBBBBB0000000

What 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_token returned 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)

DevCon MCP Workshop