TX Text Control .NET Server

Access Tokens

Topic

An access token lets a request prove that it comes from a user your application has signed in, without an authentication cookie. Tokens are what a frontend on another origin, such as an Angular or React application, uses when the cookie of the backend doesn't reach it. Text Control recommends protecting the endpoints of the Document Editor and the Document Viewer with a middleware that checks such a token. What follows shows how the backend issues tokens, how each frontend passes them, how the backend checks the requests of the viewer and how long a token should live.

How Access Tokens Work

  1. The backend issues a token. A signed-in user, or the frontend on the user's behalf, requests a token from an endpoint of your backend. In production, the token often comes from your authorization layer, such as OAuth.
  2. The frontend passes the token. The Document Editor sends it with its WebSocket handshake, and the Document Viewer with each of its requests.
  3. A middleware checks the token. It runs before the TX Text Control middleware and rejects requests without a valid token.

A sequence diagram: the client application sends a request with an access token to the security middleware, which answers an invalid token itself and passes a valid request on to the TX Text Control middleware

The samples in Text Control's blog articles compare every request with one fixed token from the configuration, to show the principle. In an application, issue a token for each user and let it expire.

Issue Tokens

The following class issues random tokens and remembers the user of each token until it expires. Create AccessTokenStore.cs:

using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;

// Issues random tokens and keeps the user of each token until it expires
public class AccessTokenStore
{
    private readonly IMemoryCache _cache;

    public AccessTokenStore(IMemoryCache cache)
    {
        _cache = cache;
    }

    public string Issue(string userName, TimeSpan lifetime)
    {
        string token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
        _cache.Set("access_token:" + token, userName, lifetime);
        return token;
    }

    public bool Validate(string? token, out string? userName)
    {
        userName = null;

        return !string.IsNullOrEmpty(token)
            && _cache.TryGetValue("access_token:" + token, out userName);
    }
}

Register the store in Program.cs before builder.Build():

builder.Services.AddMemoryCache();
builder.Services.AddSingleton<AccessTokenStore>();

A controller hands out tokens to users who are signed in with the authentication that your application already uses for its API. Create Controllers/AccessTokenController.cs:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AccessTokenController : ControllerBase
{
    private readonly AccessTokenStore _tokens;

    public AccessTokenController(AccessTokenStore tokens)
    {
        _tokens = tokens;
    }

    [HttpGet]
    public IActionResult Get()
    {
        // Valid for a working session in the editor or the viewer
        string token = _tokens.Issue(User.Identity!.Name!, TimeSpan.FromHours(8));

        return Ok(new { accessToken = token });
    }
}

The code works as follows:

  • RandomNumberGenerator.GetBytes(32) creates 32 random bytes, and Convert.ToHexString turns them into a token of letters and digits that can be used in a URL and as a WebSocket sub-protocol.
  • The token is stored in the memory cache with the name of the user and an absolute expiration. Validate finds it until it expires and returns the user it belongs to.
  • [Authorize] makes sure that only signed-in users get a token, and each token belongs to one user. GET /api/AccessToken returns a JSON object such as { "accessToken": "3F2A..." }.
  • The memory cache exists in one application instance. When several instances serve the editor and the viewer, keep the tokens in a store that all of them share.

Pass the Token From the Frontend

Each frontend has its own place for the token:

FrontendDocument EditorDocument Viewer
ASP.NET Core viewWebSocketProtocols of the HTML helper settings: a WebSocket sub-protocolThe query string of BasePath
JavaScriptwebSocketProtocols in the editor settings: a WebSocket sub-protocolNo documented setting
Angular, ReactThe query string of webSocketURLNo documented setting

In an ASP.NET Core view, the view can issue the token itself, because it runs on the server:

@using TXTextControl.Web.MVC
@using DV = TXTextControl.Web.MVC.DocumentViewer
@inject AccessTokenStore Tokens

@{
    // One token for the signed-in user and this working session
    string accessToken = Tokens.Issue(User.Identity!.Name!, TimeSpan.FromHours(8));
    string protocol = Context.Request.IsHttps ? "https://" : "http://";
}

@Html.TXTextControl().TextControl(settings => {
    settings.WebSocketProtocols = new string[] { "token_" + accessToken };
}).Render()

<div style="width: 800px; height: 600px;">

@DV.HtmlHelperExtension.TXTextControl(Html).DocumentViewer(settings => {
    settings.BasePath = protocol + Context.Request.Host + "?access_token=" + accessToken;
    settings.DocumentPath = "App_Data/contract.docx";
    settings.Dock = DV.DocumentViewerSettings.DockStyle.Fill;
}).Render()

</div>

The code works as follows:

  • @inject gets the AccessTokenStore from dependency injection, and the view issues a token for the user who requested the page.
  • Both packages define an Html.TXTextControl() extension method, so a view that imports both namespaces doesn't compile. The alias DV names the Document Viewer's namespace, and the viewer is rendered through its HtmlHelperExtension class.
  • WebSocketProtocols passes additional WebSocket sub-protocols to the editor, which sends them in the Sec-WebSocket-Protocol header of the handshake. The prefix token_ marks the entry that contains the token. WebSocket Security checks it.
  • BasePath sets the base URL at which the viewer calls the TextControl controller. With the token in its query string, every request of the viewer carries it.

In JavaScript, pass the token to TXTextControl.init with webSocketProtocols, which the browser sends in the Sec-WebSocket-Protocol header instead of the URL:

TXTextControl.init({
  containerID: "txDocumentEditor",
  webSocketURL: "wss://localhost:7118/api/TXWebSocket",
  editorSettings: { webSocketProtocols: ["token_" + accessToken] }
});

The Angular and React components have no setting for additional sub-protocols, so the token travels in the query string of webSocketURL, as Pass Access Tokens in the Angular guide shows. For the Document Viewer in JavaScript, Angular and React, the reference documents no setting for a token; serve such a frontend from the origin of the backend and protect the viewer with the sign-in cookie, as Frontends on Another Origin describes.

Check the Requests of the Document Viewer

The following middleware rejects every request to the TextControl controller of the viewer that doesn't carry a valid token. Add it to Program.cs after app.UseCors(), if your application uses CORS, and before app.UseTXDocumentViewer():

// Reject requests of the Document Viewer without a valid access token
app.Use(async (context, next) =>
{
    if (context.Request.Path.StartsWithSegments("/TextControl"))
    {
        AccessTokenStore tokens = context.RequestServices.GetRequiredService<AccessTokenStore>();

        if (!tokens.Validate(context.Request.Query["access_token"], out _))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return;
        }
    }

    await next(context);
});

The code works as follows:

  • StartsWithSegments matches the paths of the viewer's requests, such as /TextControl/GetPages, regardless of case.
  • A request without a token, or with an unknown or expired one, ends here with the status code 401. Because the middleware doesn't call next, the request never reaches the viewer.
  • The order matters: middleware runs in the order of the calls in Program.cs, so the check has to come before UseTXDocumentViewer. The WebSocket connection of the editor is checked by its own middleware between UseWebSockets and UseTXWebSocketMiddleware, as Check the Handshake shows.

The blog article Adding a Security Middleware to ASP.NET Core Web Applications to Protect TX Text Control Document Editor and Document Viewer Endpoints introduces the approach, and Implementing a Security Middleware for Angular Document Editor Applications in C# requests the token from an Angular application. Their middleware throws an exception for an invalid token, which ASP.NET Core answers with the status code 500; setting the status code 401 and ending the request tells the client what went wrong.

Choose the Lifetime of a Token

A token has to stay valid for as long as a frontend uses it, and a token that stops working ends the work of the user:

  • The Document Editor opens two WebSocket connections while it initializes, and both carry the token. After a lost connection, it tries to reconnect to its session until the time in reconnectTimeout has passed, with the same token. A token that can be used only once therefore doesn't work for the editor.
  • The Document Viewer sends the token with each of its requests while the user reads the document.

Choose a lifetime that covers a working session, as the eight hours in the samples above do, and issue a new token when the user opens the editor or the viewer again. The Life of an Editing Session describes the reconnection of the editor.

Keep Tokens Out of Logs

A token in a query string is part of the URL, and URLs end up in places that are kept for a long time: the request logs of web servers and reverse proxies, monitoring and error tracking tools and the browser history. ASP.NET Core itself logs the URL of each request, including the query string, by default. HTTPS protects the query string on its way through the network, but not in these logs.

  • Prefer the sub-protocol for the editor. A token in the Sec-WebSocket-Protocol header isn't part of the URL, and most servers don't log this header by default. The blog article Securing WebSocket Connections in ASP.NET Core using Sec WebSocket Protocol Header explains the risk.
  • Keep the URLs of your application out of its logs. Microsoft suggests setting the log level of the Microsoft.AspNetCore.Hosting category to Warning, or logging requests with your own middleware that leaves out the access_token parameter, as Security considerations in ASP.NET Core SignalR describes.
  • Keep lifetimes no longer than a session. A token that appears in a log is useless once it has expired.

Next Steps

  • WebSocket Security: check the token in the WebSocket handshake of the Document Editor.
  • Authentication: protect the token endpoint and your controllers with ASP.NET Core authentication.
  • Angular: request a token in an Angular application and pass it to the editor.
  • Secure Deployment: serve the application over HTTPS.