Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / IIS

Make ClickOnce Work With ASP.NET Forms Authentication

4.36/5 (10 votes)
20 Mar 2008CPOL10 min read 2   765  
A solution for securing access to a ClickOnce application using ASP.NET Forms authentication.

Introduction

If you have tried to secure access to an online ClickOnce deployed application from the Internet before, then you have probably run into the fact that ClickOnce doesn't support any security mechanism other than Windows Integrated, and that too, only over an intranet.

The development shop that we are working at provides an Enterprise-level application that needs to be deployed via ClickOnce (more specifically, it is a .NET 3.0 WPF XBAP), and for some of our clients, it is unacceptable to say, "Anyone on the internet will be able to download the client portion of our application, but our application security will prevent them connecting that client to the backend service". In short, we need a way to throw up some credentials and prevent the app from even being deployed to unauthorized clients.

Background

ClickOnce is a very cool piece of deployment technology, but it often seems like it hasn't received much love and affection from its daddy (read: Microsoft). Although, this may be a bit unfair, as we suspect some of its deficiencies are actually related to how ClickOnce needs to interface with Internet Explorer and FireFox.

First, we need to talk about why most traditional methods for securing websites don't work for ClickOnce. A lot of the way that ClickOnce operates is shrouded in mystery and undocumented, but this is what we think we have been able to glean about the sequence of events. This is specific to ClickOnce XBAPS, but should apply to .NET 2.0/3.0 online applications as well.

Let's assume we have Forms authentication turned on and set up to protect all the files in the deployment.

Note: This article, is not a primer on how to use/configure ClickOnce; if you are unfamiliar with the technical details of these, we would suggest that you first read as much of the MSDN documentation that you can find on these subjects and visit the ClickOnce MSDN forums.

  1. You request the deployment manifest from Internet Explorer (and Firefox in .NET 3.5, but let's not even go there right now).
  2. IE goes out to the web server and tries to request the .xbap.
  3. IIS sends back a redirect to the Forms authentication login page.
  4. We enter our login credentials, and hit Submit.
  5. Our login credentials flow back through IIS/ASP.NET, we are authenticated, and are sent back the .xbap.
  6. OK, so now, IE has the .xbap, so it will pass that over to ClickOnce.
  7. ClickOnce will attempt to re-download the .xbap (not sure why it does this, maybe IE has no facility to pass the file contents directly, or maybe ClickOnce just needs to re-download it again for security reasons).
  8. Here's where things start to break down, ClickOnce does not have the authentication cookie that IE set up. So, this request will fail.
  9. Let's say, ClickOnce has somehow been able to retrieve the .xbap. The next request for a file (probably the application manifest) will not have any security context about how to authenticate against IIS/ASP.NET, and as a result, the request will fail.

Microsoft's response to this is that you should keep the client portion (the part that gets downloaded) of your app as thin as possible, and just use application security to defend data, etc. But, this simply won't work if you:

  1. Have software that is deployed by your clients, and they don't want unauthorized users to be able to download the client portion of the program.
  2. Have valuable intellectual property in the client portion of the application that you don't want to bother obfuscating through some other means, or just plain exposing to the Internet.

The Solution

We would really just like to be able to use Forms Authentication to defend the application from being downloaded by unauthorized users, but how can we make this work?

We have one means of communicating information between the initial request made by IE and the subsequent requests that are made by ClickOnce, and that is through the manifests we deploy.

In the .xbap, we have:

XML
<dependentAssembly dependencyType="install" 
  codebase="SOME_PATH/Application.exe.manifest" size="819821">

This specifies the path to the application manifest (which will, in turn, contain information about how the rest of the files are to be deployed). The application manifest contains relative paths to the individual files in the application. (Note: Microsoft documentation says that these can be absolute paths, rather than relative, but this isn't true. We tried it out, as it would have made things significantly easier, but alas, the documentation seems to be conflicting.) This path, in the .xbap, serves as a base for all the relative paths in the application manifest.

So, how can we take advantage of this? Well, ASP.NET has a magical little piece of functionality called cookieless forms authentication. And, it looks like this:

http://SOME_DOMAIN/(F(GARBAGE))/SOME_FILE

Where GARBAGE is actually an encrypted forms authentication cookie. ASP.NET has this functionality because there are some devices that don't support cookies being transported in the HTTP session, or don't support a way to store these cookies locally.

A forms auth cookie can also be sent like this:

http://SOME_DOMAIN/SOME_FILE?AuthCookieName=ENCRYPTED_COOKIE_DATA

But, we're guessing some devices may not even support holding on to query strings. So, what the cookieless support does, is to turn the auth ticket into a fake directory in the URL path. This is perfect for us, because we actually cannot use a query string, because as soon as we do, it's considered an absolute path by ClickOnce, and rejected.

So, if we could, say, put something like this in the .xbap:

XML
<dependentAssembly dependencyType="install" 
  codebase="SOME_PATH/(F(GARBAGE))/Application.exe.manifest" size="23459">

where (F(GARBAGE)) is a validly constructed cookieless auth token for a Fforms authentication ticket that has not expired, then ASP.NET should be presented with all the information it needs to do Forms authentication on the request for the application manifest, and each subsequent file request. Remember that the codebase on the application manifest, SOME_PATH/(F(GARBAGE)), will be used as the URL base for the subsequent relative paths.

So, the plan is to write an IHttpHandler to service requests for the .xbap, and instead of serving up the requested .xbap, distill information about the current auth session, pump it into the .xbap, re-sign the .xbap, and then push that down to the client. Hooray!

There is, of course, just one more snarl in the fabric. When ClickOnce makes its first request for the .xbap, this request needs to have the authentication information. Mercifully, it seems ClickOnce uses exactly the same URL to request the .xbap as was input in IE, so, as long as we make sure IE includes a query string with the auth ticket, we are golden. And again, ASP.NET helps us here, since cookieless forms authentication can have the cookie encrypted in the query string, and also has this cookieless fake directory thing.

Using the Code

HttpHandler for Cookieless Authentication

So, we want to create our IHttpHandler:

VB
Public Class ClickOnceApplicationHandler Implements IHttpHandler
End Class

And, here's the main function for the handler. This handler will basically only be attached for the .xbap. It calls methods that are going to alter and re-sign the .xbap, and then push that down to the client.

VB
''' <summary>
''' Entry point for the handler, here we determine if we are dealing with the xbap,
''' and modify its values and resign.
''' </summary>
''' <param name="context"></param>
''' <remarks></remarks>
Public Sub ProcessRequest(ByVal context As System.Web.HttpContext) : 
    Implements System.Web.IHttpHandler.ProcessRequest
    
    Dim _path As String = context.Server.MapPath(context.Request.Path).ToLower
    If IO.File.Exists(_path) Then
        If _path.EndsWith(".xbap") Then
            'Set the correct mime type for xbap
            context.Response.ContentType = "application/x-ms-xbap"
            'Update the xbap with the cookieless authentication 
            'session information
            Dim _file As String = GenerateXbap(context, _path)
            'Write the updated file to the response
            context.Response.WriteFile(_file)
            context.Response.Flush()
            'Clean up the temporary file that has been generated.
            Directory.Delete(Path.GetDirectoryName(_file), True)
        End If
    Else
        Throw New HttpException(404, "File not found")
    End If
End Sub

GenerateXbap is, basically, going to create a temporary copy of the existing .xbap, and then modify the path, as described in the previous section, and re-sign. The real work is done here:

VB
Public Shared Sub UpdateDeployManifestAppReference( _
    ByVal depManifest As DeployManifest, ByVal pContext As HttpContext)
        Dim deployManifestPath As String = _
        Path.GetDirectoryName(depManifest.SourcePath)
        'This should be the AssemblyReference for our main application manifest
        Dim _assemRef As AssemblyReference = depManifest.EntryPoint
        'Get the cookie value to use for the cookieless authentication.
        Dim _val As String = pContext.Request.Cookies.Item( _
            FormsAuthentication.FormsCookieName).Value

    'Update the target path in the deployment manifest, 
    'this path will get reused for all the subsequent requests clickonce makes.
        _assemRef.TargetPath = String.Format("{0}(X(1)F({1})){2}{3}", _
        SiteRoot, _val, PathToManifest, AppManifestName)
End Sub

A keen observer would notice the X(1) part. We'll ignore this for now, and focus on only creating the Forms Authentication Ticket Faux Directory. Also note, that we are grabbing the forms auth cookie that ASP.NET has been kind enough to already set up for us, due to the user logging in to login.aspx.

You can delve into the attached code for how the manifest re-signing is done here, as it has been discussed elsewhere, and it isn't really related to what we are talking about here.

Now, we need to register the manifests and the deployment files with ASP.NET so that Forms authentication will work for them.

In IIS 7, we do this (note: we are using the classic pipeline in this example):

XML
<system.webServer>
    ...
    <handlers>
    <add name="Clickonce manifest file" 
    path="*.xbap" verb="*" 
    modules="IsapiModule" 
    scriptProcessor="%windir%\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll"
    resourceType="Unspecified" 
    preCondition="classicMode,runtimeVersionv2.0,bitness32" />
    <add name="Clickonce deployment files" 
    path="*.deploy" verb="*" 
    modules="IsapiModule" 
    scriptProcessor="%windir%\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll" 
    resourceType="Unspecified" 
    preCondition="classicMode,runtimeVersionv2.0,bitness32" />
    <add name="Clickonce manifest files" 
    path="*.manifest" verb="*" 
    modules="IsapiModule" 
    scriptProcessor="%windir%\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll" 
    resourceType="Unspecified" 
    preCondition="classicMode,runtimeVersionv2.0,bitness32" />
    ...

In IIS 6.0, we do this through the Internet Information Services manager. See here.

Next, we need to attach our handler for .xbap, and attach the StaticFileHandler for the regular deployment files, as we would like them to be downloaded as normal. We only have them registered with ASP.NET in order for the Forms authentication to work.

In system.web:

XML
<httpHandlers>
    <add verb="*" path="*.deploy" validate="false"
    type="System.Web.StaticFileHandler" />

    <add verb="*" path="*.manifest" validate="false" 
    type="System.Web.StaticFileHandler" />
    <add verb="*" path="*.xbap" validate="false" 
    type="ClickOnceHandler.ClickOnceApplicationHandler,ClickOnceHandler" />
</httpHandlers>

At this point, everything would be configured for the XBAP, and all the referenced files, to be protected by ASP.NET. Hooray! Of course, another problem comes up.

If the user requests the XBAP with cookieless forms auth, they will get an unique URL to the XBAP containing the faux directory. Now, although the XBAP we are sending back will have the same application identity, ClickOnce will see this unique URL as an identifier of a new install. (Note: We think this may have changed, as we saw a different behavior a few months ago, but it is what it is.) If you didn't mind that a user will have to install your application every time they visited the deployment site, this wouldn't be a problem. This would/should also occur for regular ClickOnce applications, as the deployment provider would be different every time. This is where the X(1) information that we embedded in the application manfiest codebase comes into play.

X(1) to the Rescue

X(1) basically tells ASP.NET, if it is in AutoDetect mode for Forms authentication cookies, that our "Browser" does not support cookies. So, our trick here is to have IE authenticate with a regular cookie, and then switch to using cookieless embedded directories for ClickOnce by marking the codebase (and all subsequent file requests) with X(1). Sneaky!

Our web.config to enable this dual mode looks like this:

XML
<authentication mode="Forms">
<forms loginUrl="login.aspx" name=".ADAuthCookie" timeout="20" 
cookieless="AutoDetect" enableCrossAppRedirects="true"/>
</authentication>
<authorization>
<deny users="?" />
<allow users="*" />
</authorization>

Note the enableCrossAppRedirects setting. This is required for the authentication ticket to pass from IE in normal cookie form, to the cookieless querystring/embedded directory for ClickOnce.

So, where do we do this switch? After we have signed in at login.aspx, the .xbap we are redirected to will have the forms auth cookie in the query string.

When we get redirected to our Default.aspx after logging in, we hit:

VB
Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) _
          Handles Me.Load
    Dim _XbapFileName As String = _
        System.Configuration.ConfigurationManager.AppSettings _
        .Item("SiteRoot") _
     & System.Configuration.ConfigurationManager.AppSettings.Item("XbapFileName")
    Response.Redirect(String.Format("{0}?{1}={2}", _XbapFileName, _
    FormsAuthentication.FormsCookieName, _
    FormsAuthentication.Encrypt(CType(Context.User.Identity, _
        FormsIdentity).Ticket)))
End Sub

So, we can make sure that the page we redirect to will have the query string, and thus the first request that ClickOnce makes of the .xbap will be authenticated. The codebase for the application manifest has the embedded forms auth ticket directory, and our XBAP is fully secured with Forms authentication. Hooray!

Points of Interest

The key ideas here are that we can emulate the deployment manifest to pass information between IE and the ClickOnce engine, and that ASP.NET cookieless sessions allow us to support an authenticated session on a "Browser" that does not have the same capabilities as IE. The next thing we want to hit, is what if the client goes further and wants to put an authenticating reverse proxy in front of our ClickOnce deployed app. What mechanisms do we have to have ClickOnce provide security information to, say, an ISA server?

Microsoft said it couldn't be done (or at least was unsupported), but we think we have cajoled ClickOnce (with the help of the brilliant ASP.NET) to support Forms authentication, thus improving the value of ClickOnce hundreds-fold (at least for us).

Thanks

We would like to thank some of the other intrepid adventurers in ClickOnce, as reading their blogs, etc., helped us to figure out enough of what ClickOnce was doing, to implement this solution:

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)