Condividi tramite


WOPI Framework

This blog post provides the documentation for the WOPI Framework repository, available on GitHub at https://github.com/apulliam/WOPIFramework. This repository contains a simple framework designed to accelerate building a WOPI Server with ASP.NET, along with two sample reference implementations which demonstrate how to use the WOPI Framework - one using Azure DocumentDB and one using SQL Server.

What is WOPI?

WOPI, or Web Application Open Platform Interface, is the Microsoft Office API which allows customer hosted documents to be used with Microsoft Office Web apps. This integration option is available to any Microsoft customer or partner under the terms of the Microsoft’s Cloud Storage Provider Program (CSPP).

How does a WOPI integration work?

To use Office Web Apps with a document stored in a customer application, or customer document repository, a customer application must host the appropriate Office Web App in an iframe and provide the address of its WOPI Server, which is called by the Office Web App acting as WOPI Clients. WOPI clients will not make calls to arbitrary WOPI server addresses, however. Only WOPI Servers addresses which are whitelisted through the Cloud Storage Provider Program are accepted by the Office Web Apps.

WOPI provides a Discovery Service to look up the specific Office Web URL for the combination of Office document type (e.g.: Word, Excel, PowerPoint, etc.) and document operation (e.g.: view, edit, editnew, embedview, etc.). The customer application appends the address of its WOPI server to the Office Web App URL. At minimum, a WOPI server must support document viewing by implementing the WOPI CheckFileInfo API for returning document metadata and the GetFile API for returning document contents. Below is a sequence diagram showing the interaction between the customer host app, the WOPI client and the customer WOPI server for displaying a document in an Office Online viewer app:

WOPI View Document Sequence Diagram

WOPI Servers may optionally support the WOPI API's required for Office Web app operations such as document editing, renaming, and save-as functionality. These operations support a multi-user environment, so the WOPI Server must also implement WOPI file locking API's.

The requirements for a WOPI Server and complete WOPI protocol are documented at https://wopi.readthedocs.org.

Other WOPI Samples

There is a rudimentary WOPI Server reference implementation referenced in the official WOPI documentation, located on GitHub at https://github.com/Microsoft/Office-Online-Test-Tools-and-Documentation/tree/master/samples/SampleWopiHandler. This sample implementation demonstrates how to implement the core WOPI protocol operations using an ASP.NET IHttpHandler implementation.

There is also a more complete ASP.NET WOPI "PnP" Server sample available at https://github.com/OfficeDev/PnP-WOPI, which stores files in Azure Blobs and file metadata in Azure DocumentDB.

What does the WOPI Framework provide?

While WOPI protocol uses ordinary REST services, the WOPI protocol semantics are very complicated - and inconsistent. WOPI operations are determined by a combination of the HTTP route (as determined by the URL), the HTTP verb and one or more request headers. WOPI operation parameters are provided in query string parameters, the request headers, and sometimes the request body. Likewise, WOPI operation responses use a combination of response headers and JSON encoded values in the response body. While the official WOPI sample and WOPI PnP sample provide a good starting point, the implementer inevitably has to deal with the complexities and peculiarities of the WOPI protocol.

The WOPI framework builds on the WOPI PnP sample and strives to accelerate WOPI Server development by providing a .NET library that handles all the WOPI protocol implementation. The main class in the WOPI Framework is WOPIFilesController, which is meant to be used a base class for an ASP.NET Controller in a WOPI Server implementation. This class implements the WOPI protocol and provides abstract methods for each WOPI operation, which the deriving class must implement. Each WOPI operation method takes an input class derived from the base WopiRequest class as a parameter. These WopiRequest derived classes are specific to the particular WOPI operation and contain all the appropriate WOPI parameters as properties. Each of these WopiRequest derived classes also has a set of factory methods for creating the appropriate WopiResponse derived class.

As an example, the WOPI GetFile method of the WopiFilesController class has the following signature:

     public abstract Task<WopiResponse> GetFile(GetFileRequest getFileRequest);

The GetFileRequest class is derived from WopiRequest. WopiRequest provides properties and response factories (ResponseServerError, ResponseUnauthorized, ResponseNotFound) common to all WOPI operations:

     public class WopiRequest
    {
        internal WopiRequest(HttpRequestMessage httpRequestMessage, string resourceId)
        {
            RequestUri = httpRequestMessage.RequestUri;
            ResourceId = resourceId;
            var queryStringParameters = httpRequestMessage.RequestUri.ParseQueryString();
            AccessToken = queryStringParameters[WopiQueryStrings.ACCESS_TOKEN];
            AppEndpoint = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.APP_ENDPOINT);
            ClientVersion = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.CLIENT_VERSION);
            CorrelationId = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.CORRELATION_ID);
            DeviceId = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.DEVICE_ID);
            MachineName = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.MACHINE_NAME);
            Proof = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.PROOF);
            ProofOld = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.PROOF_OLD);
            Timestamp = GetHttpRequestHeader(httpRequestMessage, WopiRequestHeaders.TIME_STAMP);
        }
        public string ResourceId { get; private set; }
        public Uri RequestUri { get; private set; }
        public string AccessToken { get; private set; }
        public string AppEndpoint { get; private set; }
        public string ClientVersion { get; private set; }
        public string CorrelationId { get; private set; }
        public string DeviceId { get; private set; }
        public string MachineName { get; private set; }
        public string Proof { get; private set; }
        public string ProofOld { get; private set; }
        public string Timestamp { get; private set; }

        internal static string GetHttpRequestHeader(HttpRequestMessage httpRequestMessage, string header)
        {
            IEnumerable<string> matches;
            if (httpRequestMessage.Headers.TryGetValues(header, out matches))
                return matches.FirstOrDefault();
            return null;
        }

        public WopiResponse ResponseServerError(string serverError)
        {
            return new WopiResponse()
            {
                StatusCode = HttpStatusCode.InternalServerError,
                ServerError = serverError
            };
        }
       
        public WopiResponse ResponseUnauthorized()
        {
            return new WopiResponse()
            {
                StatusCode = HttpStatusCode.Unauthorized
            };
        }

        public WopiResponse ResponseNotFound()
        {
            return new WopiResponse()
            {
                StatusCode = HttpStatusCode.NotFound
            };
        }

    }

The GetFileRequest class provides properties (MaxExpectedSize) and response factory methods (ResponseOK, ResponseFileTooLarge) that are specific to the GetFile operation:

     public class GetFileRequest : WopiRequest
    {
        public GetFileRequest(HttpRequestMessage httpRequestMessage,string fileId) : base(httpRequestMessage, fileId)
        {
            IEnumerable<string> matchingHeaders;
            if (httpRequestMessage.Headers.TryGetValues(WopiRequestHeaders.MAX_EXPECTED_SIZE, out matchingHeaders))
                MaxExpectedSize = Int64.Parse(matchingHeaders.FirstOrDefault());
        }

        public long? MaxExpectedSize { get; private set; }

      
        public GetFileResponse ResponseOK(HttpContent content)
        {
            return new GetFileResponse()
            {
                StatusCode = HttpStatusCode.OK,
                Content = content
            };
        }

        public GetFileResponse ResponseFileTooLarge()
        {
            return new GetFileResponse()
            {
                StatusCode = HttpStatusCode.PreconditionFailed
            };
        }
    }

The response factory methods assure that WOPI response contain all required values. Optional values can be set directly on the created response. In the case of the GetFile operation, ItemVersion is optional:

     public class GetFileResponse : WopiResponse
    {
        internal GetFileResponse()
        {
        }

        public HttpContent Content { get; internal set; }

        public string ItemVersion { get; set; }

        public override HttpResponseMessage ToHttpResponse()
        {
            var httpResponseMessage = base.ToHttpResponse();
            if (StatusCode == HttpStatusCode.OK)
            {
                SetHttpResponseHeader(httpResponseMessage, WopiResponseHeaders.ITEM_VERSION, ItemVersion);
                httpResponseMessage.Content = Content;
            }
            return httpResponseMessage;
        }
    }

While it is still important to understand the basics of the WOPI protocol and the purpose of the various WOPI operations, the WOPI Framework classes provide a “sandbox” to free the developer from dealing with the complexities of the WOPI protocol by providing only the appropriate parameters for each WOPI operation, and (using factory methods) ensuring that only valid WOPI responses are constructed.

How is the WOPI Framework used?

The WOPI Framework uses the latest version of ASP.NET – 4.6.1. To use the WOPI Framework, create an ASP.NET 4.6.1 Web Application project in Visual Studio 2015, then include and reference the Microsoft.Dx.Wopi project.

The WopiFilesController class is meant to be used as a base class to a Controller class in your project:

     public class MyFilesController : WopiFilesController

The WopiFilesController classes uses ASP.NET attribute based routing. Unfortunately, attribute routing is not inheritable by default and attribute inheritance must be enabled manually. This is done by overriding the DefaultDirectRouteProvider:

     public class CustomDirectRouteProvider : DefaultDirectRouteProvider
    {
        protected override IReadOnlyList<IDirectRouteFactory>
        GetActionRouteFactories(HttpActionDescriptor actionDescriptor)
        {
            return actionDescriptor.GetCustomAttributes<IDirectRouteFactory>
            (inherit: true);
        }
    }

This class is registered in the Register method of the WebApiConfig class prior to the default route definition:

     public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Ignore AAD Auth for WebAPI...will be handled by WopiFileController
            config.SuppressDefaultHostAuthentication();

            // Web API routes
            config.MapHttpAttributeRoutes(new CustomDirectRouteProvider());

            config.Routes.MapHttpRoute(
                name: "WopiDefault",
                routeTemplate: "wopi/{controller}/{id}/{action}",
                defaults: new { id = RouteParameter.Optional, action = RouteParameter.Optional }
            );
        }
    }

Afterward, the class deriving from the WOPI Framework WopiFilesController class just has to implement the required abstract methods corresponding to the WOPI operations, plus the Authorize method:

         public abstract Task<bool> Authorize(WopiRequest wopiRequest);
      
        public abstract Task<WopiResponse> CheckFileInfo(CheckFileInfoRequest checkFileInfoRequest);
       
        public abstract Task<WopiResponse> GetFile(GetFileRequest getFileRequest);

        public abstract Task<WopiResponse> Lock(LockRequest lockRequest);

        public abstract Task<WopiResponse> Unlock(UnlockRequest unlockRequest);

        public abstract Task<WopiResponse> UnlockAndRelock(UnlockAndRelockRequest unlockRequest);

        public abstract Task<WopiResponse> RefreshLock(RefreshLockRequest refreshLock);

        public abstract Task<WopiResponse> GetLock(GetLockRequest getLockRequest);

        public abstract Task<WopiResponse> PutRelativeFileSpecific(PutRelativeFileSpecificRequest putRelativeFileSpecificRequest);

        public abstract Task<WopiResponse> PutRelativeFileSuggested(PutRelativeFileSuggestedRequest putRelativeFileSuggestedRequest);

        public abstract Task<WopiResponse> RenameFile(RenameFileRequest renameFileRequest);

        public abstract Task<WopiResponse> PutUserInfo(PutUserInfoRequest putUserInfoRequest);

        public abstract Task<WopiResponse> PutFile(PutFileRequest putFileRequest);

Both the Microsoft.Dx.WopiServerSql and Microsoft.Dx.WopiServerDocumentDb projects provide examples of how to use the WOPI Framework classes.

Why are there two WOPI Server samples included with the WOPI Framework? What’s the difference?

The Microsoft.Dx.WopiServerDocumentDb sample is simply a port of the existing WOPI PnP sample, available here on Github. No changes, other than using the WOPI Framework, were made to this sample, in order to show the same code, with and without the WOPI Framework.

I chose to implement a second, SQL Server-based, WOPI server (Microsoft.Dx.WopiServerSql) for several reasons:

  1. At the time I developed the WOPI Framework, Azure DocumentDB was not available in all Azure regions.

  2. Most developers (and myself) are more familiar with SQL Server than DocumentDB. While most legacy applications probably aren’t written using Entity Framework, I chose Entity Framework because it was much faster (and easier) than using ADO.NET. Hopefully, it should be easy for any SQL developer to understand the Entity Framework code.

  3. I wanted sample, separate from the PNP WOPI sample, where I could implement additional WOPI functionality.

Besides the different document metadata stores, the main difference between the Microsoft.Dx.WopiServerDocumentDb and the Microsoft.Dx.WopiServerSql WOPI Server implementations is a different strategy for using access tokens with WOPI. Also, Microsoft.Dx.WopiServerSql is is designed to allow concurrent user document access for testing by allowing users to see files from other users in the same Azure AD tenant, but this functionality is not complete yet.

How does WOPI security work?

There are two types of security in a WOPI Server implementation: security between the WOPI Client and WOPI Server and security for the documents served by the WOPI Server.

Security between WOPI Client and WOPI Server

WOPI is a protocol that allows Office Web Apps to access and edit documents stored in customer applications or customer document repositories. In addition to the technical requirement of implementing a WOPI Server, any customers delivering a WOPI integration also must meet the contractual obligations of the Microsoft Cloud Storage Provider Program. To ensure that Office Web Apps will only work with approved WOPI integrations, only whitelisted WOPI Server domains are accepted by the Office Web Apps. While whitelisting is used by WOPI clients to validate WOPI Servers, a digital signature, referred to as WOPI Proof, is provided with each WOPI API call so that WOPI servers can validate WOPI clients. The WOPI Proof value can be validated from a set of rotating WOPI Proof keys in the WOPI Discovery Service response. The WOPI Proof Key validation is handled transparently by the WOPI Framework.

WOPI Server Document Security

It is up to the WOPI Server implementation to decide whether and how to secure access to its hosted documents. The WOPI protocol provides support for a WOPI implementation-created access token that the WOPI Server can use for authentication and/or authorization.

The WOPI protocol documentation specifies that the hosting app for documents should load the Office Web into dynamically created iframes via a form POST. The HTML/JavaScript code below shows how this is done:

 <form id="office_form" name="office_form" target="office_frame" action='@ViewData["wopi_urlsrc"]' method="post">
    <input name="access_token" value='@ViewData["access_token"]' type="hidden" />
    <input name="access_token_ttl" value='@ViewData["access_token_ttl"]' type="hidden" />
</form>

<span id="frameholder"></span>

<script type="text/javascript">
    var frameholder = document.getElementById("frameholder");
    var office_frame = document.createElement("iframe");
    office_frame.name = "office_frame";
    office_frame.id ="office_frame";
    frameholder.appendChild(office_frame);
    document.getElementById("office_form").submit();
</script>

The URL for the Office Web App, which is determined by lookup from the WOPI Discovery Service, is the POST action ULR for the form. The implementation created access token and its TTL values are included in hidden HTML form fields.

The Office Web App, acting as a WOPI Client, will then pass this access token back to the WOPI Server as a query string parameter on each WOPI API call. The WOPI Server can use this token to determine if the user is authorized to access, edit or perform other operations on the target document. The WOPI Framework supports the access token validation via the Authorize abstract method, which is called before each WOPI operation.

The access token is used primarily for user authentication by the WOPI Server, and may or may not be used for authorization. The Microsoft.Dx.WopiServerDocumentDb sample creates a Java Web Token (JWT) upfront which contains the user identity claim, as well as authorization claims that determine the rights the user has to the target document. The Microsoft.Dx.WopiServerSql sample, on the other hand, only stores an identity claim, and relies on a permissions table in the SQL Server database for authorization decisions.

What are the limitations of the WOPI Framework?

  • WOPI Framework only supports ASP.NET WOPI Servers. The WOPI Framework is mainly just a set of C# classes to simplify WOPI Server programming and encapsulate the protocol complexities. The same approach could be used to deliver a WOPI Framework for other development platforms, such as Node.js. Let me know if there’s interest.

  • Currently the WOPI Framework only supports Office Web Apps integration. There is no support for Office for iOS integration (iOS WOPI API's) at this time.

  • While WOPI supports concurrent document access, and the SQL Server WOPI Server sample is designed to allow concurrent document access, the support for concurrent access in the WOPI Framework and SQL Server WOPI Server sample hasn’t yet been fully tested.