SharePoint 2013: Building your own WOPI Client, part 2

Tags: SharePoint 2013, WOPI, WAC Server

Welcome back to another part in my Building a WOPI Client series. In the previous and first post I walked you through the basics of the WOPI protocol, how the WOPI Discovery mechanism worked and how to implement it and finally how to register a WOPI Client with SharePoint 2013 as WOPI Server. In this post we’ll continue to build on our C# Viewer and now actually add the viewer – we ended the last post quite dull with just showing a simple Hello WOPI web page which we now are going to turn into a real C# viewer.

The WOPI Protocol Specification (currently) lacks some details…

The WOPI (Web Application Open Platform Interface) Protocol specification is currently in draft mode (version 0.2) and most of what is in this post is not defined in the specs, especially regarding the security and signing stuff. All the details in this post comes from extensive trial and error, and might be wrong – I’m trying to keep the post up to date once the specs are finalized.

How the WOPI Client and WOPI Server interaction works

Before we take a look at the code and how it’s implemented it is crucial to understand how the WOPI Client and WOPI Server talks to each other. When a user request to use one of the WOPI app actions the server will send a request to the WOPI Client on the address specified in the Discovery document (the urlsrc attribute specifies the address). The WOPI Server sends the “callback” address and access tokens to the WOPI Client so that the client can initiate contact and request the required resources to generate the WOPI Client interface. The callback URL is sent in the query string to the client and has the name WOPISrc,. It also sends an access token and its time to live as the request body. The WOPISrc URL has the following form HTTP://server/<...>/wopi*/files/<id> as described in the [MS-WOPI] 3.3.5. This URL is used by the client to request information about the document that it is going to generate the view for. For instance if we do an HTTP GET to the WOPISrc URL then we get metadata about the document, if we do an HTTP GET to the WOPISrc and appends “/contents” we get the actual content of the document or an HTTP POST to the “/contents” means that we’re updating the document.

WOPI Security

We cannot just request the resources as mentioned above, the WOPI client must also pass the access token, given to the client from the server, to make sure that the WOPI Client just don’t try to access files it doesn’t have access to. The access token must be sent as a query string parameter to the WOPI Server and as the Authorization header of the HTTP request. When building a WOPI Client we don’t have to care about what’s in the actual access token, just that it’s there and that we need to pass it on every request. But this is not enough we also need to send the X-WOPI-Proof header back to the WOPI Server. Remember from the last post that we specified a proof-key (which is the public version of the key), it is here that private key is used, to sign the X-WOPI-Proof header. Most of this is not yet in the [MS-WOPI] specs, so bare with me if I got something wrong. But hey, it works on my machine!

Building a WOPIWebClient

Since we need to add quite a few headers and query string parameters to each and every request from our WOPI client to the WOPI Server I have baked all this into a WebClient derivative, called WOPIWebClient. This class will make sure that all the necessary plumbing are there for us to make secure calls to the server:

public class WOPIWebClient: WebClient
{
    private readonly string _access_token;
    private readonly string _access_token_ttl;
    private readonly DateTime _utc; 

    public WOPIWebClient(string url, string access_token, string access_token_ttl)
    {
        _access_token = access_token;
        _access_token_ttl = access_token_ttl;
        _utc = DateTime.UtcNow;
    }
    protected override WebRequest GetWebRequest(Uri address)
    {
        UriBuilder builder = new UriBuilder(address);

        string append = String.Format("access_token={0}&access_token_ttl={1}", 
            _access_token, 
            _access_token_ttl);

        if(builder.Query == null || builder.Query.Length <=1){
            builder.Query = append;
        } else {
            builder.Query = builder.Query.Substring(1) + "&" + append;
        }

        WebRequest request = base.GetWebRequest(builder.Uri);
        if (request is HttpWebRequest)
        {
            // Add AuthZ header
            request.Headers.Add(
                HttpRequestHeader.Authorization, 
                String.Format("Bearer {0}", 
                    HttpUtility.UrlDecode(_access_token.Replace("\n", "").Replace("\r", ""))));

            request.Headers.Add(
                "X-WOPI-Proof", 
                WOPIUtilities.Sign(
                    WOPIUtilities.CreateProofData(builder.Uri.ToString(), _utc, _access_token)));
                
            // TODO: Add X-WOPI-ProofOld here

            request.Headers.Add(
                "X-WOPI-TimeStamp", 
                _utc.Ticks.ToString(CultureInfo.InvariantCulture));

            request.Headers.Add(
                "X-WOPI-ClientVersion",
                "Wictor.WOPIClient.1.0");

            request.Headers.Add(
                "X-WOPI-MachineName",
                Environment.MachineName);
        }
        return request;
    }
}

Let’s walk through the code. First of all we have a constructor that takes three arguments; the url to use to call back to the WOPI Server and the access_token and the time to live value for the access token (access_token_ttl). Then I override the GetWebRequest method of the WebClient so that I can add the required headers and modify the URL before it sends it away. First of all in that overridden method we need to add the access token and the ttl to the query string (as access_token and access_token_ttl). Once that is done we can create the WebRequest and then add our headers. First of all is to add the Authorization header, that header must have the value of “Bearer <access_token>” (see [MS-WOPI] 2.2.1).

It’s now where things starts to get tricky. We need to send the X-WOPI-Proof header to the server. This is a set of not so random bytes that are signed using our private proof key. In the code above I first create the proof using a utility method called CreateProofData and then sign the data using another utility method, called Sign. We’ll take a look at the implementation of those in a minute. If you are changing proof keys you should also add the old proof by using the X-WOPI-ProofOld header. After that I add the current time stamp in the X-WOPI-TimeStamp header.

Finally I add a couple of other headers (also defined in 2.2.1) which are optional but might help you debug or troubleshoot your WOPI Client. When SharePoint is the WOPI Server you can see this data for all incoming requests in the ULS logs.

Creating the proof data

The proof data must be correctly generated otherwise the WOPI Server will not accept the request. The proof data is an array of bytes consisting of the access token, the requested url and the current timestamp, which we also send in clear text through the HTTP headers. Since the X-WOPI-Proof header is also signed before it is sent to the server, the WOPI Server can validate that no one tampered with the url or token. This is how the proof data is created:

public static byte[] CreateProofData(string url, DateTime time, string accesstoken)
{
    UTF8Encoding encoding = new UTF8Encoding();
    byte[] accessbytes = encoding.GetBytes(
        HttpUtility.UrlDecode(accesstoken));
    byte[] urlbytes = encoding.GetBytes(
        new Uri(url).AbsoluteUri.ToUpperInvariant());
    byte[] ticksbytes = getNetworkOrderBytes(time.Ticks);

    List<byte> list = new List<byte>();
    list.AddRange(getNetworkOrderBytes(accessbytes.Length));
    list.AddRange(accessbytes);
    list.AddRange(getNetworkOrderBytes(urlbytes.Length));
    list.AddRange(urlbytes);
    list.AddRange(getNetworkOrderBytes(ticksbytes.Length));
    list.AddRange(ticksbytes);
    return list.ToArray();
}

Using the access token, URL and time/ticks we convert them to byte arrays and add them to a list of bytes, each byte array is prefixed with the length of the byte array. Finally the list is casted into one big byte array – this is our proof that will be signed. Also worth to notice is that the Ticks and length bytes has to be converted to the reversed order, that is flip the byte order significance. For that I use a method, with two overloads, like this:

private static byte[] getNetworkOrderBytes(int i)
{
    return BitConverter.GetBytes(IPAddress.HostToNetworkOrder(i));
}
private static byte[] getNetworkOrderBytes(long i)
{
    return BitConverter.GetBytes(IPAddress.HostToNetworkOrder(i));
}

Signing the proof data

To make sure that the data is not tampered with or created by some fake WOPI client, we need to sign this proof data. This is done using the private key we generated in the first post. We used the following script to generate the proof-key (used by the WOPI Server to verify the signed data) and the data with a private key that is used to sign the proof data:

$crypt = New-Object System.Security.Cryptography.RSACryptoServiceProvider -ArgumentList 2048
$proof = [System.Convert]::ToBase64String($crypt.ExportCspBlob($false))
$proofwithkey = [System.Convert]::ToBase64String($crypt.ExportCspBlob($true))
$proof
$proofwithkey

In our signing method we’ll use the $proofwithkey value, like this (the actual data is cropped for obvious reasons):

public static string Sign(byte[] data)
{
    using (RSACryptoServiceProvider provider = new RSACryptoServiceProvider(2048))
    {
        provider.ImportCspBlob(
            Convert.FromBase64String("BwIAAACkA...yMp3k="));
        var signed = provider.SignData(data, "SHA256");
        return Convert.ToBase64String(signed);
    }
}

This method takes the byte array (proof data) as input, encrypts it using our private proof key and then returns the data as a Base64 encoded string. This is demo code and the private proof key is in this case hardcoded – in a production system you would like to have this stored in a secure and manageable store.

The VS2012 SolutionThat is all that is required to make a WOPI WebClient, now let’s put it into use…

Building the UI

To build the C# Viewer I decided to use Syntax Highlighter by Alex Gorbatchev, so I downloaded the latest drop and added the script and CSS files to the Web project for the WOPI Client. I also added a custom CSS that I use for some custom UI styling (remember that I’m a kick ass UI designer as well!).

Next up is to create our user interface. The viewer.aspx file that we created in the previous post with just some dummy text is next up on the agenda.

First of all is to add the scripts and CSS files into the head element, like this:

<head runat="server">
    <title></title>
    <link href="/styles/WOPIClient.css" rel="stylesheet" />
    <script type="text/javascript" src="/scripts/shCore.js"></script>
    <script type="text/javascript" src="/scripts/shBrushCSharp.js"></script>
    <link type="text/css" rel="stylesheet" href="/styles/shCoreDefault.css"/>
    <script type="text/javascript">SyntaxHighlighter.all();</script> 
</head>

The final script row in the snippet above is used to start the syntax highlighter.

The actual UI is composed of a header with some document information and then an area where the document contents should be shown, using the syntax highlighter. This is how the body of the viewer.aspx looks like:

<body>
    <form id="form1" runat="server">
        <div id="topdiv">
            <asp:HyperLink ID="hlSite" runat="server" Text="Site"/>  / 
            <asp:Label ID="lblDocument" runat="server" Text="Document.cs"/>
        </div>
        <div id="surface">
            <pre class="brush: csharp" style="overflow:auto;overflow-y:scroll">
                <asp:Literal ID="litCode" runat="server" />
            </pre>
        </div>
    </form>
</body>

The topbar contains a Hyperlink control that we will use to link back to the library where the document resides and it also contains a Label control which will show the document name. The document area just contains a simple Literal control where we will output the C# file contents.

Now onto the code behind implementation of this viewer.aspx, this is how it looks like:

protected void Page_Load(object sender, EventArgs e)
{
    string src = Request.QueryString["WOPISrc"];
            
    if (!String.IsNullOrEmpty(src))
    {
        string access_token = Request.Form["access_token"];
        string access_token_ttl = Request.Form["access_token_ttl"];

        // Get the metadata
        string url = String.Format("{0}", src);
        using (WOPIWebClient client =
             new WOPIWebClient(url, access_token, access_token_ttl))
        {
            string data = client.DownloadString(url);
            JavaScriptSerializer jss = new JavaScriptSerializer();
            var d = jss.Deserialize<Dictionary<string, string>>(data);
            hlSite.NavigateUrl = d["BreadcrumbFolderUrl"];
            hlSite.Text = d["BreadcrumbFolderName"];
            lblDocument.Text = d["BaseFileName"];
        }

        // Get the content
        url = String.Format("{0}/contents", src);
        using (WOPIWebClient client = 
            new WOPIWebClient(url, access_token, access_token_ttl))
        {
            string data = client.DownloadString(url);
            litCode.Text = data;
        }
    }
}

The Page_Load method starts by retrieving the WOPISrc URL which is the URL that we will use to call back into the WOPI Server. The next thing it fetches is the access_token and access_token_ttl query form values – these are sent back with the request to the WOPI Server (as we discussed above).

We are doing two calls to the WOPI Server. The first one, HTTP GET, using the exact WOPISrc URL to retrieve the metadata about the document. The WOPI Server returns a JSON construct that I convert into a Dictionary so that we can retrieve the name of the library/folder and its URL as well as the name of the document. There are several more properties that can be used, for full reference of this data structure see [MS-WOPI] 3.3.5.1.1.2.

The second HTTP GET call uses a modified URL, we append “/contents”. This will return back the actual document data from the WOPI Server. Since a C# file is just a text file, we’ll just set that data to the Literal control.

This ends the coding and configuring of our WOPI Client, let’s see if it works!

Test it!

To test it you must first register your WOPI Client, unless you did it before. If you have changed anything in the discovery xml, then you need to re-register it. See the previous post on how to register and unregister the WOPI bindings.

Once the client is registered, just upload a C# (.cs) file to any document library and then click on the title of the file. You should be redirected (if you just registered or re-registered the bindings an IISRESET might be necessary) to the WOPI frame and start the WOPI client and it should look something like this:

A C# WOPI Client

Mission accomplished, isn’t that beautiful!

Summary

You have now over two posts seen how to build a C# Viewer for SharePoint 2013 (and Exchange 2013 and Lync 2013) implemented as a WOPI Client, according to the [MS-WOPI] protocol specification. There’s a few moving parts but I hope that I have helped you through all the quirks, and by “borrowing” this code you should be up and running pretty fast.

I see a big opportunity window here for ISV’s or other software companies to build viewers and editors for their own document/data formats. I know there is a huge demand of products like this, for instance for drawings such as AutoCAD or why not a viewer for Photoshop PSD files?

I have a plan on what to do with the code that I have been using to build these demos, watch this space for more info. But before that I’ll add some more features to our WOPI client in the near future.

No Comments

  • Murad said

    I had some issues getting this to work. It seems that it is now required to enabled OAuth over http to make it work over http.

    http://technet.microsoft.com/en-us/library/ff431687(v=office.15).aspx#scenario1

    $config = (Get-SPSecurityTokenServiceConfig)
    $config.AllowOAuthOverHttp = $true
    $config.Update()

  • Florian Jousseau said

    Hello Wictor, I try to follow your web site, but i've got some issue sometimes because i didn't understand every thing for 2 reasons I'm french and olso I'm a beginner in ASP.net. But i have to work with this WOPI Protocol and you're the only one person i saw on internet using this protocol, that's why i send you this comment. I really would like to meet you by internet to share about this protocol. I'm going to send you a mail as well. I'm still a student, and I start juste now the ASP.NET language, i know very well some language like C++ JAVA SQL.
    I really would like to see all the source of this mini project but i'm not sure if it's possible.

    I'm waiting for your answer
    Sorry for my English.
    Best regards
    Florian Jousseau

  • Anbarasan DN said

    Hi,

    I'm trying to configure Office WebApps 2013 - I have installed pre requirements as http://technet.microsoft.com/en-us/library/jj219455.aspx and now I'm trying to add new server farm "New-OfficeWebAppsFarm –InternalURL http://OWAPILOT –AllowHttp -EditingEnabled" but I have an error 'Logon failure - unknown user name or bad password' - how to solve this? Please guide me

    Using Windows Server 2008 SP1

    Installed .Net Framework 4.5, Windows Poweshell, and Office Web Apps Server

    I am running Powershell as Admin and Executing the Following Code:

    PS C:\Users\Administrator> New-OfficeWebAppsFarm -InternalURL http://OWAPILOT -AllowHttp -EditingEnabled

    Got this Output:

    New-OfficeWebAppsFarm : Logon failure: unknown user name or bad password.
    At line:1 char:1
    + New-OfficeWebAppsFarm -InternalURL http://OWAPILOT -AllowHttp -EditingEnabled
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : NotSpecified: (:) [New-OfficeWebAppsFarm], AuthenticationException
    + FullyQualifiedErrorId : System.Security.Authentication.AuthenticationException,Microsoft.Office.Web.Apps.Administration.NewFarmCommand

    ----------------------------------------------------------------------------------------------------------------

    When i execute the following in command prompt

    C:\Windows\system32>sc qc wacsm

    Got Output:

    [SC] QueryServiceConfig SUCCESS

    SERVICE_NAME: wacsm
    TYPE : 10 WIN32_OWN_PROCESS
    START_TYPE : 2 AUTO_START
    ERROR_CONTROL : 1 NORMAL
    BINARY_PATH_NAME : "C:\Program Files\Microsoft Office Web Apps\AgentMa
    nager\Microsoft.Office.Web.AgentManager.exe"
    LOAD_ORDER_GROUP :
    TAG : 0
    DISPLAY_NAME : Office Web Apps
    DEPENDENCIES :
    SERVICE_START_NAME : LocalSystem

    C:\Windows\system32>

    --------------------------------------------------------------------------------------------

    Server Machine is part of a domain also.

    --------------------------------------------------------------------------------------------

    HKLM\SYSTEM\CurrentControlSet\services\eventlog and subkey “Microsoft Office Web Companions”

    If the key exists please delete it and then re-run the New-Officewebappsfarm command.

    Key is not Exists in Registry

  • Taps said

    Wictor,

    I am using the custom WOPI Host from here: http://code.msdn.microsoft.com/office/Building-an-Office-Web-f98650d6
    and using your custom WOPI client so do I need to sign the data differently?
    I am stuck at how to sign data so that I get the file. Please suggest what I need to change in your code for custom WOPI host.

  • Taps said

    Wictor,

    I am able to open text files, do you have any idea why I couldn't get it to work for Word (Office) files? I am getting data in TextPad format for word files instead of rich text.

Comments have been disabled for this content.

About Wictor...

Wictor Wilén is a Director and SharePoint Architect working at Connecta AB. Wictor has achieved the Microsoft Certified Architect (MCA) - SharePoint 2010, Microsoft Certified Solutions Master (MCSM) - SharePoint  and Microsoft Certified Master (MCM) - SharePoint 2010 certifications. He has also been awarded Microsoft Most Valuable Professional (MVP) for four consecutive years.

And a word from our sponsors...

SharePoint 2010 Web Parts in Action