developers

Building Secure AI Agents with Microsoft Agent Framework and Auth0: Sending Email with Token Vault

Allow your .NET AI agent to send emails on your behalf securely using the Auth0 Token Vault.

Aug 19, 2026 โ€ข 26 min read

In the previous two parts of this series (part 1 and part 2), we progressively built an AI agent capable of displaying expense reports from a manager's direct reports and identifying missing information. However, at the current stage, the agent can't act on this knowledge. The manager must still manually email employees to request missing details and then remember to follow up. That's exactly the kind of overhead an AI agent should absorb.

In this part, you will equip the agent with a SendMissingDataEmail() tool. When the agent identifies a missing required field, such as amount, merchant, or justification, it will automatically send an email to the employee using the manager's Gmail account. The manager themselves don't need to touch the email client, the agent will act on the managerโ€™s behalf.

How the agent gets Gmail access is the interesting part. Rather than storing Google OAuth tokens in your application database, you will use Auth0 Token Vault. After a one-time authorization by the manager, Auth0 securely acquires and stores the Google access and refresh tokens. When the agent needs to send an email, it requests a short-lived access token from Auth0, uses it, and immediately discards it, ensuring the token never resides in your application state.

Here's a security detail that matters too: the Gmail token must never reach the LLM. If it appears in a tool argument or response, the model could leak it. To prevent this, we use constructor injection: the token is captured by the tool class before the agent runs, keeping it invisible to the tool's schema.

Configuring Auth0 and Your Agent for Token Vault

Let's start the journey by configuring Auth0 for using the Token Vault. This is a secure storage system for access and refresh tokens from external providers, such as Google, GitHub, Microsoft, etc. When a user authorizes an external provider via OAuth, Auth0 stores those tokens in the Token Vault. Your application can then exchange a valid Auth0 token for an external provider's token, allowing it to call external APIs on the user's behalf, without you needing to build custom integrations for each provider.

The Token Vault setup for using Gmail API requires five steps:

  1. Registering your agent with Google services. This lets Google know about your app and its intentions to use Gmail to send emails.
  2. Enabling the Token Vault for the Google connection. This step enables your Google connection in Auth0 to use the Token Vault.
  3. Enabling the Token Vault for your application. This enables your application in Auth0 to use the Token Vault.
  4. Enabling My Account API for connected accounts. This step ensures that the manager's account on Auth0 and their Google account are linked so that they are not required to re-authenticate to Google when requesting an access token for Gmail.
  5. Updating Auth0 authentication configuration in your application code. This is about adjusting your application configuration.

Register your agent with Google services

To use the Gmail API with Auth0, first create an OAuth 2.0 client in the Google Developer Console. Then, use those settings to configure the Google connection in your Auth0 dashboard. For details, refer to Auth0's Google integration guide or watch the video below:

Ensure you have enabled the https://www.googleapis.com/auth/gmail.send scope for your client in the Google Console.

Enable Token Vault on the Google connection

In the Auth0 dashboard, follow these steps:

  1. Go to Authentication > Social and select "Google".
  2. If you didn't do so during the Google services registration step, make sure you enter your Google OAuth client credentials.
  3. In the Purpose section, select Authentication and Connected Account for Token Vault: Screenshot of the Auth0 dashboard Google connection settings, highlighting the 'Enable Token Vault' switch in the on position.
  4. Under Permissions, check the Offline Access checkbox in the Access Type subsection. This ensures Google returns a refresh token, which Token Vault needs to get fresh access tokens without re-prompting the user.
  5. Still under Permissions, check the Gmail.Send permission in the Gmail subsection: Screenshot of the Auth0 dashboard Google connection settings, showing the 'Offline Access' and 'Gmail.Send' permissions enabled.
  6. Save the settings you changed.

Enable Token Vault on your application

Now, to enable the Token Vault for the expense agent:

  1. Go to the Applications section of the dashboard and select the expense agent.
  2. In the Settings tab, scroll down to the Advanced Settings section and select the Grant Types tab.
  3. Enable Token Vault: Screenshot of the Auth0 application advanced settings in Auth0, showing the 'Token Vault' grant type enabled.
  4. Make sure to save changes.

While in the Settings tab of the expense agent, take note of the Client Secret. We are going to use it.

Enable the My Account API for Connected Accounts

Later in this article, the manager will link Gmail after logging in. Authorizing Gmail access happens separately from login itself. That path goes through Auth0's My Account API, which needs its own one-time setup:

  1. Go to Applications > APIs and locate the My Account API entry. Select Activate if it isn't already active: Activate the My Account API in the Auth0 dashboard

    Its audience is https://YOUR_AUTH0_DOMAIN/me/.

  2. Create a Client Grant authorizing the expense agent application to call the My Account API with the create:me:connected_accounts scope. From Applications > APIs > My Account API, select the Application Access tab and locate the expense agent application. Click the corresponding Edit button, select the create:me:connected_accounts permission and click the Grant Access button: ![Set permissions for the My Account API in the Auth0 dashboard](https://images.ctfassets.net/23aumh6u8s0i/4SLgHlGyzx8fGixw00Hdbj/a242a769fcc049e4067a81c0ab7fa365/my-account-api-permissions-auth0-dashboard.png Finally save the changes.
  3. Configure a Multi-Resource Refresh Token (MRRT) policy on the application so the manager's existing login refresh token can be silently exchanged for a My Account API token, without prompting them to log in again. Navigate to Applications > Applications and select the expense agent. In the Settings tab, scroll down to reach the Multi-Resource Refresh Token section. Click the Edit Configuration button and then enable the MRRT toggle for the Auth0 My Account API.: Enable Multi-Resource Refresh Token in the Auth0 dashboard

    Save the changes on the settings page.

With this in place, your server can turn the manager's existing session into a My Account API token on demand. This is the same "fetch it right before you need it" pattern you're already using for Gmail tokens via Token Vault, but targeting a different resource.

See our introduction to the My Account API and the Connected Accounts for Token Vault reference.

Update the Auth0 authentication configuration

To complete the setup, let's make a couple of changes in the server-side code of the expense agent.

Until now, you only needed an ID token for authentication. Requesting an access token plus a refresh token means the application has to authenticate against Auth0's authorization server, which is why you now need the client secret.

Also, while the Auth0 ASP.NET Core Authentication SDK sends the openid, profile, and email scopes by default, now you need to add the offline_access scope too so Auth0 requests a refresh token to Google during login. Update the code as shown below:

// ExpenseAgent/Program.cs

//...existing code...

builder.Services.AddAuth0WebAppAuthentication(options =>
{
    options.Domain = builder.Configuration["Auth0:Domain"]!;
    options.ClientId = builder.Configuration["Auth0:ClientId"]!;
    //๐Ÿ‘‡new code
    options.ClientSecret = builder.Configuration["Auth0:ClientSecret"]!;
    options.Scope = "openid profile email offline_access";
    //๐Ÿ‘†new code
})
//๐Ÿ‘‡new code
.WithAccessToken(options =>
{
    options.UseRefreshTokens = true;
});
//๐Ÿ‘†new code

//...existing code...

In summary, the first time a manager logs in after this change, Auth0 will redirect to Google's consent screen asking for Gmail permission. After they grant it, Token Vault holds the refresh token and handles silent renewal from that point on.

The .WithAccessToken(...) call turns on the SDK's MRRT support: UseRefreshTokens = true exchanges the manager's stored refresh token for an access token scoped to a different audience (like the My Account AP) entirely server-side, without a second login round trip. You'll use this later, when a manager who didn't log in with Google needs to link Gmail separately.

Edit the appsettings.json file and add the client secret setting as shown in the following code snippet:

// ExpenseAgent/appsettings.json

{
  //...existing settings
  
  "Auth0": {
    "Domain": "YOUR_AUTH0_DOMAIN",
    "ClientId": "YOUR_CLIENT_ID",
    "ClientSecret": "YOUR_CLIENT_SECRET"  //๐Ÿ‘ˆ new setting
  },
  
  //...existing settings
}

Everything is configured to request a Gmail access token from Google and manage it through the Token Vault. Let's start building the flow to get the tokens and send the email when an expense report is missing some data.

To access the Token Vault features through the Auth0 SDK, make sure you have installed Auth0.AspNetCore.Authentication v1.9.0 or later.

As mentioned before, a manager who didn't log in with Google needs to link Gmail separately. You need a way to let the manager authorize your agent to access Gmail. Let's start by creating a GmailLinkContext.cs file in the Services folder:

// ExpenseAgent/Services/GmailLinkContext.cs

namespace ExpenseAgent.Services;

public class GmailLinkContext
{
    public bool NeedsLink { get; set; } = true;
    public string? Cookie { get; set; }
}

This service holds two small things. NeedsLink tracks whether the manager still needs to authorize Gmail access; the UI banner reads it. Cookie captures the manager's raw authentication cookie during the initial render, so the interactive SignalR circuit can later authenticate a call back to your own server. You will learn why you need this infrastructure in a moment.

Register the service as a scoped service as shown below:

// ExpenseAgent/Program.cs

//...existing code...

//๐Ÿ‘‡new code
builder.Services.AddScoped<GmailLinkContext>();
//๐Ÿ‘†new code

var app = builder.Build();

//...existing code...

Make sure you register this service as a scoped service, not singleton: a singleton would leak one manager's cookie into every other manager's SignalR circuit.

Now, update ExpenseAgent/Components/App.razor to populate it during the initial render:

// ExpenseAgent/Components/App.razor

@* ๐Ÿ‘‡new code *@
@using Auth0.AspNetCore.Authentication
@using ExpenseAgent.Services

@inject GmailLinkContext LinkContext
@*๐Ÿ‘†new code *@
  
@* ...existing head/body markup... *@

//๐Ÿ‘‡new code
@code {
    [CascadingParameter]
    private HttpContext? HttpContext { get; set; }

    protected override async Task OnInitializedAsync()
    {
        if (HttpContext?.User.Identity?.IsAuthenticated == true)
        {
            LinkContext.Cookie = HttpContext.Request.Headers.Cookie.ToString();

            var token = await HttpContext.GetAccessTokenForConnectionAsync(
                new AccessTokenForConnectionRequest { Connection = "google-oauth2" });

            LinkContext.NeedsLink = token is null;
        }
    }
}
//๐Ÿ‘†new code

If the user is authenticated, the component calls GetAccessTokenForConnectionAsync() during the initial render. This method, provided by the Auth0 SDK, obtains an access token from the Token Vault, but in this context it is only used to answer one question: has this manager linked Gmail? The result is discarded immediately; only the yes/no answer survives, in LinkContext.NeedsLink.

HttpContext.Request.Headers.Cookie gives you the raw Cookie header the browser sent on this same request. That's what gets forwarded later, when the SignalR circuit needs to authenticate its own call back to the server.

Since Blazor relies on SignalR for client-server communication, you have some limitations on accessing HttpContext. HttpContext is only populated during the initial, statically-rendered pass of a Razor component. This opens a problem for invoking GetAccessTokenForConnectionAsync() after the initial rendering. This is why we are capturing in LinkContext the two pieces of state the rest of the app needs. This is done in the App.razor component, the one that handles the actual HTTP request for the page.

Building the Email Service

With the Gmail link status plumbing in place, build the actual email-sending logic. Add an EmailService.cs file to the Services folder with the following code:

// ExpenseAgent/Services/EmailService.cs

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace ExpenseAgent.Services;

public class EmailService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public EmailService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task SendAsync(
        string toEmail,
        string subject,
        string body,
        string gmailToken)
    {
        var rawMessage =
            $"To: {toEmail}\r\n" +
            $"Subject: {subject}\r\n" +
            $"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
            $"{body}";

        // Gmail API requires base64url encoding
        var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(rawMessage))
            .Replace('+', '-')
            .Replace('/', '_')
            .TrimEnd('=');

        var httpClient = _httpClientFactory.CreateClient();
        httpClient.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", gmailToken);

        var response = await httpClient.PostAsync(
            "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
            new StringContent(
                JsonSerializer.Serialize(new { raw = encoded }),
                Encoding.UTF8,
                "application/json"));

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            throw new InvalidOperationException(
                $"Gmail send failed ({response.StatusCode}): {error}");
        }
    }
}

The SendAsync() method builds the message and encodes it with base64url encoding, as the Gmail messages/send endpoint requires (RFC 2822). Then it calls the Gmail endpoint passing the gmailToken.

Register the EmailService in Program.cs with the following code:

// ExpenseAgent/Program.cs

//...existing code...

builder.Services.AddScoped<GmailLinkContext>();
//๐Ÿ‘‡new code
builder.Services.AddSingleton<EmailService>();
//๐Ÿ‘†new code

var app = builder.Build();

//...existing code...

Sending the Email

Now, you need a way to get the chat component to send the email message with the Gmail token. Due to the Blazor restrictions to HttpContext mentioned before, you can't invoke GetAccessTokenForConnectionAsync() in the context of the component itself. So you will add an endpoint that does the actual work: fetching a fresh token and calling Gmail.

Open the Program.cs file and add the lines of code highlighted below:

// ExpenseAgent/Program.cs

//...existing code...

//๐Ÿ‘‡new code
app.MapPost("/api/send-missing-data-email", async (
    SendMissingDataEmailRequest request,
    HttpContext httpContext,
    EmailService emailService) =>
{
    var token = await httpContext.GetAccessTokenForConnectionAsync(
        new AccessTokenForConnectionRequest { Connection = "google-oauth2" });

    if (token is null)
    {
        return Results.Conflict(new { needsLink = true });
    }

    await emailService.SendAsync(request.EmployeeEmail, request.Subject, request.Body, token);
    return Results.Ok();
}).RequireAuthorization();
//๐Ÿ‘†new code

app.MapStaticAssets();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(ExpenseAgent.Client._Imports).Assembly);

app.Run();

//๐Ÿ‘‡new code
public record SendMissingDataEmailRequest(string EmployeeEmail, string Subject, string Body);
//๐Ÿ‘†new code

.RequireAuthorization() means this endpoint only runs for an authenticated request. An anonymous caller gets a 401 before the handler body executes at all.

Inside the handler, httpContext is a real instance of request-scoped HttpContext, so GetAccessTokenForConnectionAsync() behaves exactly as it does in App.razor: it returns a token that's guaranteed fresh at the moment it's requested, refreshed silently from the stored Google refresh token if needed. That token exists only as a local variable for the lifetime of this one request. It's never assigned to a field, never cached anywhere, and never appears in the response.

On success you get 200 OK; if the manager hasn't linked Gmail, you get 409 Conflict with { needsLink: true }. Either way, the caller learns what happened without learning anything about the token itself.

This endpoint is meant to be called only by your own server code, not directly by a browser. That raises the obvious next question: a caller with no HttpContext of its own, i.e., the interactive SignalR circuit, needs some way to authenticate to it. This is what the cookie captured on page load is for, and in the next section you'll see how it is used.

Updating the Agent Tools

To enable your agent to send the email, you will add a new SendMissingDataEmail() tool. Open the ExpenseAgentTools.cs file in the ExpenseAgent/Agent folder and apply the changes highlighted in the following code block:

// ExpenseAgent/Agent/ExpenseAgentTools.cs

using System.ComponentModel;
using System.Text.Json;
using ExpenseAgent.Services;
using System.Net;            //๐Ÿ‘ˆ new code
using System.Net.Http.Json;  //๐Ÿ‘ˆ new code

namespace ExpenseAgent.Agent;

public class ExpenseAgentTools
{
    private readonly string _managerId;
    private readonly ExpenseReportService _expenseService;
    private readonly FgaService _fgaService;
    private readonly HttpClient _httpClient;  //๐Ÿ‘ˆ new code
    private readonly string? _cookie;         //๐Ÿ‘ˆ new code

    public ExpenseAgentTools(
        string managerId,
        ExpenseReportService expenseService,
        FgaService fgaService,
        HttpClient httpClient,  //๐Ÿ‘ˆ new code
        string? cookie)         //๐Ÿ‘ˆ new code
    {
        _managerId = managerId;
        _expenseService = expenseService;
        _fgaService = fgaService;
        _httpClient = httpClient;  //๐Ÿ‘ˆ new code
        _cookie = cookie;          //๐Ÿ‘ˆ new code
    }

    //...existing code...

    //๐Ÿ‘‡new code
    [Description("Send an email to an employee asking them to provide missing information for their expense report.")]
    public async Task<string> SendMissingDataEmail(
        [Description("The employee's email address")] string employeeEmail,
        [Description("The expense report ID (e.g., exp-002)")] string expenseId,
        [Description("Comma-separated list of missing fields (e.g., 'amount, justification')")] string missingFields)
    {
        var subject = $"Action Required: Incomplete Expense Report {expenseId}";
        var body = $"""
            Hi,

            Your expense report {expenseId} is missing the following required fields: {missingFields}.

            Please update your submission with the missing information so it can be reviewed and approved.

            Thank you.
            """;

        using var request = new HttpRequestMessage(HttpMethod.Post, "/api/send-missing-data-email")
        {
            Content = JsonContent.Create(new { employeeEmail, subject, body })
        };

        if (_cookie is not null)
        {
            request.Headers.Add("Cookie", _cookie);
        }

        var response = await _httpClient.SendAsync(request);

        if (response.StatusCode == HttpStatusCode.Conflict)
        {
            return "I can't send this yet โ€” the manager still needs to authorize Gmail access.";
        }

        if (!response.IsSuccessStatusCode)
        {
            return $"Sending the email to {employeeEmail} failed. Please try again.";
        }

        return $"Email sent to {employeeEmail} requesting: {missingFields}.";
    }
    //๐Ÿ‘†new code
}

The constructor of ExpenseAgentTools takes two new parameters: an HttpClient instance and the manager's cookie.

The new SendMissingDataEmail() tool will build the text of the email and invoke the sending endpoint you created earlier, including the authentication cookie.

Notice that from the LLM's point of view this is an ordinary tool invocation. No token is shared with it. It calls SendMissingDataEmail() with employeeEmail, expenseId, and missingFields, and gets back a short status string. How the email actually gets sent, i.e., the token fetch, the Gmail call, happens one hop away, inside /api/send-missing-data-email, invisible to both the model and this class.

Rule of thumb: never hand credentials to an LLM. Anything it can see, it can leak.

For more on why agents shouldn't hold secrets, see Want AI Agents That Don't Spill Secrets? Don't Give Them Secrets.

Updating the System Prompt

The agent needs explicit instructions for evaluating completeness. Without them, it might describe the problem but not act on it. Update the system prompt in Agent/ExpenseAgentService.cs as shown below:

// ExpenseAgent/Agent/ExpenseAgentService.cs

//...existing code...

var systemPrompt = """
    You are an expense approval assistant. You help managers review and process expense reports from their direct reports.

    For each expense report you review, check whether it includes all three required fields:
    1. Amount (must be greater than zero)
    2. Merchant name (must not be empty)
    3. Justification (must not be empty)

    If any required field is missing:
    - Call SendMissingDataEmail with the employee's email, the expense ID, and
      a comma-separated list of the missing field names.
    - Tell the manager what you did and which fields were missing.

    If all required fields are present:
    - Tell the manager the report looks complete and is ready for their decision.

    Be concise and professional. Address the manager by name when you know it.
    """;
       
//...existing code...

Updating the Chat Component

The chat component will read the manager's link status and cookie from GmailLinkContext, and build an HttpClient for ExpenseAgentTools to call the new endpoint with.

Update Components/Pages/Chat.razor as shown below:

@* ExpenseAgent/Components/Pages/Chat.razor *@

@* ...existing code... *@

@inject ExpenseAgentService AgentService
@inject ExpenseReportService ExpenseService
@inject FgaService FgaService
@inject GmailLinkContext LinkContext          //๐Ÿ‘ˆ new code
@inject IHttpClientFactory HttpClientFactory  //๐Ÿ‘ˆ new code
@inject NavigationManager Navigation          //๐Ÿ‘ˆ new code

<PageTitle>Expense Agent</PageTitle>

<h2>Expense Agent</h2>

<AuthorizeView>
    <Authorized Context="auth">
        <p class="text-muted">Logged in as <strong>@auth.User.Identity?.Name</strong></p>
    </Authorized>
</AuthorizeView>

@* ๐Ÿ‘‡new code *@
@if (_needsGoogleLink)
{
    <div class="alert alert-warning">
        To send emails on your behalf, I need access to your Gmail account.
        <a href="/Account/ConnectGoogle" class="alert-link">Authorize Gmail access</a>
    </div>
}
@* ๐Ÿ‘†new code *@

@* ...existing markup... *@

@code {
    [CascadingParameter]
    private Task<AuthenticationState>? AuthState { get; set; }

    private readonly List<(string Role, string Text)> _messages = new();
    private string _inputText = "";
    private string? _userId;
    private string? _userName;
    private bool _thinking;
    private string? _cookie;         //๐Ÿ‘ˆ new code
    private bool _needsGoogleLink;   //๐Ÿ‘ˆ new code
    private HttpClient? _apiClient;  //๐Ÿ‘ˆ new code

    protected override async Task OnInitializedAsync()
    {
        if (AuthState is not null)
        {
            var state = await AuthState;
            _userId = state.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
            _userName = state.User.FindFirst("name")?.Value
                ?? state.User.FindFirst(System.Security.Claims.ClaimTypes.GivenName)?.Value;
        }

        //๐Ÿ‘‡new code
        _cookie = LinkContext.Cookie;
        _needsGoogleLink = LinkContext.NeedsLink;

        _apiClient = HttpClientFactory.CreateClient();
        _apiClient.BaseAddress = new Uri(Navigation.BaseUri);
        //๐Ÿ‘†new code
    }

    private async Task SendMessage()
    {
        var text = _inputText.Trim();
        //๐Ÿ‘‡changed line
        if (string.IsNullOrEmpty(text) || _userId is null || _apiClient is null) return;

        _inputText = "";
        _messages.Add(("You", text));
        _thinking = true;

        try
        {
            //๐Ÿ‘‡changed line
            var tools = new ExpenseAgentTools(
                _userId, ExpenseService, FgaService, _apiClient, _cookie);

            var reply = await AgentService.ChatAsync(_userId, text, _userName, tools);
            _messages.Add(("Agent", reply));
        }
        finally
        {
            _thinking = false;
        }
    }

    private async Task HandleKeyDown(KeyboardEventArgs e)
    {
        if (e.Key == "Enter") await SendMessage();
    }
}

IHttpClientFactory creates the HttpClient that calls back into your own app, and NavigationManager.BaseUri gives it a BaseAddress. That's not optional: a plain HttpClient in a Blazor Server circuit has no base address of its own, unlike a WebAssembly client, which resolves relative requests against the page it's running in. Without setting BaseAddress explicitly, a relative request like /api/send-missing-data-email throws InvalidOperationException before it ever leaves the process.

LinkContext.Cookie supplies the credential the internal call authenticates with. Chat.razor never sees a Gmail token, because there isn't one to see.

The "Authorize Gmail access" alert asks for user authorization for accessing Gmail. This is what the next section will focus on.

Handling the Google Connection Flow

To access your expense agent, the manager must prove who they are, and it's tied to whatever method they actually use to sign in: Google, a database connection, whatever your tenant offers on the Universal Login page.

Linking Gmail here is a different problem: the manager is already signed in, under whatever identity they used, and now wants to attach a Google account's tokens to that existing profile so the agent can act on their behalf. Auth0 keeps these two concerns separate. The mechanism for the second one is called Connected Accounts, and it's a distinct flow against a different API (the My Account API you already enabled and configured), not a variation on login.

This is the goal of the "Authorize Gmail access" link shown above the chat window if the agent has no Gmail access token yet:

Expense AI agent shows a gmail access authorization alert

The Connected Accounts handshake

Connecting an account is a three-step round trip against the My Account API:

  1. Initiate Connection. Your server uses the My Account API to start the flow. It passes to Auth0 the target connection (google-oauth2 in our case) and where to send the browser back to after approval on Google (redirectUri). The API returns a response containing ConnectUri, a one-time Ticket, and an AuthSession that your server holds onto for step 3.
  2. User Authorization. You redirect the browser to ConnectUri?ticket=.... The manager consents on Google's authorization screen.
  3. Complete Connection. Google redirects back to your redirectUri. The single-use connect_code issued by Auth0 returns in the URL fragment (after #). A small snippet of client-side JavaScript extracts the fragment and posts it back to your backend endpoint. Your backend then invokes the My Account API to finalize account linking and save the credentials into Token Vault.

That fragment detail is the whole reason this needs a dedicated callback page instead of a plain redirect target: whatever handles the return trip has to run in the browser for at least one step, purely to pull connect_code out of window.location.hash before it disappears.

Both the Initiate and Complete Connection steps need to authenticate as the logged-in manager against the My Account API, which is a different audience (https://YOUR_AUTH0_DOMAIN/me/) than anything you've requested a token for so far. That's exactly what the .WithAccessToken(options => options.UseRefreshTokens = true) change from earlier in this article set up: HttpContext.GetAccessTokenAsync() can silently exchange the manager's existing login refresh token for a My Account API token, on demand, the same "ask right before you need it" pattern already used for Gmail tokens.

The Connected Account implementation

For the actual implementation of the flow, you will use the Auth0 My Account API SDK for .NET, which abstracts away lower-level HTTP request formatting, authorization header handling, and response schema parsing. In the ExpenseAgent folder install the SDK with the following command:

dotnet add package Auth0.MyAccountApi

At the time of writing, the Auth0 My Account API SDK for .NET is released in beta version. Make sure to update to the latest version in case you have any issues.

First, register the My Account client and its token provider in Program.cs:

// ExpenseAgent/Program.cs

//...existing code...

//๐Ÿ‘‡new code
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Auth0.MyAccountApi;
//๐Ÿ‘†new code

//...existing code...

builder.Services.AddScoped<ExpenseAgentService>();

//๐Ÿ‘‡new code
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(sp =>
{
    var domain = sp.GetRequiredService<IConfiguration>()["Auth0:Domain"]!;
    var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();

    return new MyAccountClient(new MyAccountClientOptions
    {
        Domain = domain,
        TokenProvider = new DelegateTokenProvider(async cancellationToken =>
        {
            var context = httpContextAccessor.HttpContext;
            if (context == null) return null;

            return await context.GetAccessTokenAsync(new AccessTokenRequest
            {
                Audience = $"https://{domain}/me/",
                Scope = "create:me:connected_accounts"
            });
        })
    });
});
//๐Ÿ‘†new code

//...existing code...

This code creates a singleton service for the MyAccountClient client, which will help you to make calls to the My Account API.

To implement the three steps outlined in the previous subsection, add the three endpoints shown below to Program.cs:

//...existing code...

//๐Ÿ‘‡new code
app.MapGet("/Account/ConnectGoogle", async (
    HttpContext ctx,
    IConfiguration config,
    MyAccountClient myAccountClient,
    IDistributedCache cache) =>
{
    // Initiate a connected account flow using the Auth0 My Account client.
    var correlationId = Guid.NewGuid().ToString("N");
    var redirectUri = $"{ctx.Request.Scheme}://{ctx.Request.Host}/Account/ConnectGoogleCallback?cid={correlationId}";

    try
    {
        var response = await myAccountClient.ConnectedAccounts.CreateAsync(
            new CreateConnectedAccountsRequestContent
            {
                Connection = "google-oauth2",
                RedirectUri = redirectUri,
                State = correlationId
            }
        );

        // Cache the minimal pending state so the callback/complete flow can resume.
        await cache.SetStringAsync(
            $"connect:{correlationId}",
            JsonSerializer.Serialize(new { auth_session = response.AuthSession, redirect_uri = redirectUri }),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });

        return Results.Redirect($"{response.ConnectUri}?ticket={response.ConnectParams.Ticket}");
    }
    catch
    {
        return Results.Redirect("/chat");
    }
}).RequireAuthorization();

app.MapGet("/Account/ConnectGoogleCallback", (string cid) => Results.Content($$"""
    <!DOCTYPE html>
    <html>
    <body>
      <script>
        var connectCode = new URLSearchParams(window.location.hash.substring(1)).get('connect_code');
        fetch('/Account/ConnectGoogleComplete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ cid: '{{cid}}', connectCode: connectCode })
        }).then(() => { window.location.href = '/chat'; });
      </script>
      Finishing Gmail authorization&hellip;
    </body>
    </html>
    """, "text/html")).RequireAuthorization();

app.MapPost("/Account/ConnectGoogleComplete", async (
    HttpRequest req,
    HttpContext ctx,
    IConfiguration config,
    MyAccountClient myAccountClient,
    IDistributedCache cache) =>
{
    // Read JSON payload manually to avoid requiring a dedicated request type.
    using var sr = new StreamReader(req.Body);
    var body = await sr.ReadToEndAsync();
    if (string.IsNullOrEmpty(body)) return Results.BadRequest();

    var doc = JsonSerializer.Deserialize<JsonElement>(body);
    if (!doc.TryGetProperty("cid", out var cidProp) || !doc.TryGetProperty("connectCode", out var codeProp))
    {
        return Results.BadRequest();
    }

    var cid = cidProp.GetString()!;
    var connectCode = codeProp.GetString()!;

    var pendingJson = await cache.GetStringAsync($"connect:{cid}");
    if (pendingJson is null)
    {
        return Results.BadRequest();
    }

    var pending = JsonSerializer.Deserialize<JsonElement>(pendingJson);
    var authSession = pending!.GetProperty("auth_session").GetString();
    var redirectUri = pending.GetProperty("redirect_uri").GetString();

    try
    {
        await myAccountClient.ConnectedAccounts.CompleteAsync(
            new CompleteConnectedAccountsRequestContent
            {
                AuthSession = authSession!,
                ConnectCode = connectCode,
                RedirectUri = redirectUri!
            }
        );

        await cache.RemoveAsync($"connect:{cid}");
        return Results.Ok();
    }
    catch
    {
        return Results.BadRequest();
    }
}).RequireAuthorization();
//๐Ÿ‘†new code

app.MapStaticAssets();

//...existing code...

The /Account/ConnectGoogle endpoint is responsible for initiating the connected account flow. It calls the myAccountClient.ConnectedAccounts.CreateAsync() method and redirects the browser to the ConnectUri (Google in our case) with the specified ticket. The relevant data is stored in cache to be recovered later at completion time.

The /Account/ConnectGoogleCallback endpoint is where Google will redirect the manager after they approve Gmail usage on their behalf. It just returns a HTML page with a JavaScript code snippet that sends the correlation ID and the connectCode to the third endpoint: /Account/ConnectGoogleComplete.

Important: Add https://YOUR_APP_URL/Account/ConnectGoogleCallback to the application's Allowed Callback URLs in the Auth0 dashboard, alongside whatever callback path your login flow already uses: Auth0 validates redirectUri against that list for this flow too.

The /Account/ConnectGoogleComplete endpoint gathers the three values needed to complete the connected account flow and invokes myAccountClient.ConnectedAccounts.CompleteAsync().

Each of these three endpoints must have .RequireAuthorization() and runs as a normal browser-initiated HTTP request (not a Blazor circuit call), so HttpContext.User is populated as usual from the manager's authentication cookie. There's no need for the cookie-forwarding trick ExpenseAgentTools uses elsewhere. That trick exists specifically because the SignalR circuit has no HttpContext of its own; a plain page navigation and a fetch() from that page both do.

One consequence of separating identity from Connected Accounts is worth spelling out. A manager who signs in with Google itself at Universal Login already goes through Google's consent screen at login time. The offline_access scope you added earlier means Token Vault already has a usable Google token before they reach the chat page.

GetAccessTokenForConnectionAsync() in App.razor finds one, NeedsLink comes back false, and the "Authorize Gmail access" banner never appears for them. The Connected Accounts flow in this section only comes into play for a manager who authenticates some other way (a database connection or a different social provider) and needs Gmail linked as a secondary account after the fact. Both paths land tokens in the same place in Token Vault; they just start from different logins.

Testing the Email Flow

Start the app and log in. Because offline_access is now in the scope, Auth0 will redirect to Google's consent screen on the first login after this change. Authorize Gmail access.

Once back in the app, navigate to /chat and ask: "Review all pending expense reports and handle any that are missing information."

The agent calls GetExpenseReports, retrieves the reports authorized for the current manager, and evaluates each one. For exp-002 (missing amount and justification), it calls SendMissingDataEmail. For exp-003 (missing merchant), it calls SendMissingDataEmail again.

Check the test employee email accounts: the messages arrive from the manager's Gmail address, not from any system-level email address. The agent acted on behalf of the manager, using their own account.

What Happened Under the Hood

Here's the full flow in one diagram, which is worth a look even after building it, because the sequence explains why each moving part exists:

Flow diagram of sending Gmail messages by an AI agent using Auth0 Token Vault

At login (1), offline_access in the scope (2) causes Google to return a refresh token alongside the access token (3). Auth0 stores them in the Token Vault, associated with the user's Auth0 session (4).

When the /chat page first loads (5), App.razor renders once with a real HttpContext, the one HTTP request in the whole Blazor Server lifecycle. It calls GetAccessTokenForConnectionAsync() purely to check whether the manager has linked Gmail (6), discards the result (7), and keeps only the yes/no answer in GmailLinkContext.NeedsLink. It also captures the request's Cookie header into GmailLinkContext.Cookie (8) and finally renders the chat view (9).

When the agent decides to send an email, SendMissingDataEmail() never touches Token Vault directly. It POSTs to /api/send-missing-data-email on your own server, forwarding the manager's cookie so the request authenticates as them (10).

That endpoint runs as a real, authenticated HTTP request with its own HttpContext. It calls GetAccessTokenForConnectionAsync() right there (11) and Token Vault returns a token that's fresh at that exact moment (14), silently refreshed from the stored refresh token if needed (12, 13). Then it uses that token to call Gmail (15), and lets it go out of scope the instant the request ends (16, 17).

Where This Leaves Us

After this walkthrough, the agent can now:

  • Evaluate expense reports for completeness
  • Email employees on the manager's behalf when data is missing
  • Use the manager's own Gmail account, not a system address
  • Acquire OAuth tokens without storing them in your application

The last piece is approval. exp-001 (Alice's hotel dinner) has all three required fields: amount, merchant, and justification. Right now, the agent just says "this one looks ready." In part 4, you'll give it the ability to send a push notification to the manager's phone and wait for an approval or rejection via CIBA.

About the author

Andrea Chiarelli

Andrea Chiarelli

Principal Developer Advocate

I have over 20 years of experience as a software engineer and technical author. Throughout my career, I've used several programming languages and technologies for the projects I was involved in, ranging from C# to JavaScript, ASP.NET to Node.js, Angular to React, SOAP to REST APIs, etc.

In the last few years, I've been focusing on simplifying the developer experience with Identity and related topics, especially in the .NET ecosystem.

View profile