📝 Use case: your app hosts files in Azure Blob Storage and users can generate expiring links to these files (using SAS tokens)

🤕 Problem: when a user opens a link with an expired SAS token, an unfriendly XML is displayed containing a technical error

😊 Solution: proxy the link with SAS token through an endpoint (Azure Function in my case), which will either redirect to the file (if the token is still valid) or show a descriptive error message


Recently the sex education app which we’ve created together with Kasia KoczułapMarek Majchrzak and Radek Czemerys had a requirement to generate expiring links to files. As we store our files in Azure Blob Storage, using SAS tokens was an obvious choice — for each file requested, our backend generates a link with SAS token valid for 30 minutes:

https://{blobPath}?sv=2019-02-02&sr=b&sig=6KHcOyMni6PTld/V0WUojJO2ORxcDVdgRgIklH0hV5k=&se=2021-01-10T20:32:58Z&sp=r

It works great when the token is valid, but here’s what the user sees after opening an expired link:

More technical users may figure out that the link expired. Others may think that the app is broken or the link got corrupted during copying.

Not only this page does not explain what happened — it also does not provide the next steps. Users may feel lost and in effect, they may leave a bad review or ask support for help, even though they could fix this situation by regenerating the link.

Fixing the flow

A better UX would be to show a clear & detailed error message in case the link expired. The way I approached this in our app was to proxy the original link with SAS token through a new HTTP Triggered Azure Function. This function checks whether the token is still valid and if yes, then simply redirects to the file. Otherwise, it returns a simple HTML page with a descriptive message.

The fact that I’ve used Azure Function does not mean you can not use the same logic in your WebAPI endpoints (or whatever else you’re using). For us Azure Function was an obvious choice, as we use them heavily in our project 🏋🏻‍♀️

How to detect link expiration?

You may have noticed that the link with SAS token has a few parameters:

https://{blobPath}?sv=2019-02-02&sr=b&sig=6KHcOyMni6PTld/V0WUojJO2ORxcDVdgRgIklH0hV5k=&se=2021-01-10T20:32:58Z&sp=r

sv=2019–02–02 is a version of the API which was used to generate this SAS token
sr=b means that the SAS token was created for a blob
sig is the signature of the token
sp=r means that the token is valid only for reading

… and:

se=2021–01–10T20:32:58Z, which specifies the moment when the SAS token expires. So in order to verify that the token is still valid, we just need to check whether the se parameter contains a date which is in the future — easy peasy! ✨

Code

Here’s a proof of concept of all of the stuff described above. If you’d like to run it or you’d need the list of nugets used, check out the full project here: https://github.com/miszu/expire-sas-token-handling

// StorageAccountConnectionString should be a key in your Function App Configuration (or local.settings.json for local development)
// It's value should contain a connection string to your Storage Account.
[StorageAccount("StorageAccountConnectionString")]
public static class ExpiredSasTokenHandling
{
    private const string ContainerName = "YOUR_CONTAINER_NAME";
    private const string FilePath = "/PATH/TO/YOUR/FILE.pdf";
    private const string FullFilePath = ContainerName + FilePath;

    [FunctionName("generateFileLink")]
    public static IActionResult GenerateFileLink(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]
        HttpRequest request, [Blob(FullFilePath)] CloudBlobContainer container)
    {
        var accessPolicy = new SharedAccessBlobPolicy()
        {
            SharedAccessExpiryTime = DateTime.UtcNow.AddHours(1),
            Permissions = SharedAccessBlobPermissions.Read
        };

        var sasToken = container.GetSharedAccessSignature(accessPolicy);
        var authenticatedBlobLink = new Uri(container.Uri + FilePath + sasToken);

        var linkToFriendlyProxy = new UriBuilder
        {
            Scheme = request.Scheme,
            Host = request.Host.Host,
            Port = request.Host.Port.GetValueOrDefault(80),
            Path = $"api/{FileProxyFunctionName}",
        };

        var query = HttpUtility.ParseQueryString(linkToFriendlyProxy.Query);
        query[FileProxyUrlParameter] = authenticatedBlobLink.ToString();
        linkToFriendlyProxy.Query = query.ToString();

        return new OkObjectResult(linkToFriendlyProxy.ToString());
    }

    private const string FileProxyFunctionName = "fileProxy";
    private const string FileProxyUrlParameter = "originalUrl";
    private const string StorageHost = "YOUR_STORAGE_ACCOUNT_NAME.blob.core.windows.net";

    [FunctionName(FileProxyFunctionName)]
    public static IActionResult FileProxy(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]
        HttpRequest request)
    {
        var blobUrlWithToken = request.Query.ContainsKey(FileProxyUrlParameter)
            ? request.Query[FileProxyUrlParameter][0]
            : null;

        if (!Uri.TryCreate(blobUrlWithToken, UriKind.Absolute, out var blobUrl))
        {
            return GetInvalidLinkHtml();
        }

        // You should only redirect to your own resources for safety (more info - 'Open redirect vulnerability')
        if (!string.Equals(blobUrl.Host, StorageHost, StringComparison.InvariantCultureIgnoreCase))
        {
            return GetInvalidLinkHtml();
        }

        // Show error if token's validity date is not there or is in the past
        var validityDateParameterValue = HttpUtility.ParseQueryString(blobUrl.Query).GetValues("se");
        if (validityDateParameterValue?.Any() != true ||
            !DateTime.TryParse(validityDateParameterValue.First(), out var validityDateTime) ||
            DateTime.UtcNow > validityDateTime.ToUniversalTime())
        {
            return GetInvalidLinkHtml();
        }

        return new RedirectResult(blobUrlWithToken);
    }

    private static IActionResult GetInvalidLinkHtml() => new ContentResult()
    {
        // Keep calm, it's just HTML. You can consider to use a library to build it safer (for example HtmlGenerator)
        Content =
            $"<html><body><div style=\"text-align: center; margin: 5%; margin-top: 20%\"><p style=\"font-size: 4vh; font-family:'San Francisco'\">This link is not valid anymore, please go back to the app and regenerate it.</p></div></body></html>",
        ContentType = "text/html"
    };
}

Let me know if you have any doubts or questions. If something above was useful to you, I’d be extremely grateful for any feedback you may have. Good luck with whatever you’re doing! 🙌🏻

Write A Comment