Friday, February 8, 2008

Web Application vs ClickOnce + WebService - how to share Forms Authentication

One of the most powerful technologies available on the .NET platform is the ClickOnce which makes it possible to implement selected components of a web application as desktop applications.

It is a common architectural pattern to build a web service for the clickonce desktop component so that no matter which user interface technology runs on the client side (web or desktop), the application logic is always executed on the server side. It is also a common pattern to secure the web application using Forms Authentication.

This artice focuses on following issue: sharing the forms authentication between web and desktop components of the application.

The Ultimate Goal

My goal is to build a typical web application secured with Forms Authentication. The application will expose selected parts of its functionality as a ClickOnce application. What I'd like to have is a way to run the ClickOnce application from within the web application and share the identity a user is logged as. In other words - when a user authenticates via the login page of the web application, he/she should not be forced to reauthenticate when the ClickOnce application starts but rather the ClickOnce application should automatically share the user credentials with the web application.

The solution should be also secured in a sense that users who are not authenticated in a web application should not be able to execute any methods in the ClickOnce application that require the web service call.

In yet other words - both web application and the ClickOnce application should share the same Forms Authentication.

The Solution

The solution is based solely on the Forms Authentication semantics, specifically - the FormsAuthenticationModule.

You see, on succesfull login, a cookie is appended to the server's response and the cookie is passed between a web browser and the application server. However, from the application server's perspective there's no difference between a web browser call to a web page and a ClickOnce application call to a web service. In both cases the FormsAuthenticationModule checks if the authentication cookie is properly passed by the HTTP protocol and if this is so, the application server treats the incoming request as authenticated.

If we would be able to somehow pass the authentication cookie from the web page to the ClickOnce application (so that the authentication cookie would be appended to any web service calls) the issue would be solved. Fortunately, this is not difficult at all!

First, build your web application as usual. Implement your MembershipProvider and the login page. Then, on a selected web page put a link to invoke the ClickOnce application.

<asp:HyperLink ID="TheLinkButton" runat="server">
    Run my ClickOnce application</asp:HyperLink>

The hyperlink will be dynamically evaluated so that the authentication cookie would be passed to the ClickOnce application:



protected void Page_Load( object sender, EventArgs e )
 {
     this.TheLinkButton.NavigateUrl = GenerateUrl;
 }
 
 protected string GenerateUrl
 {
     get
     {
         // if the authentication cookie is set within the web application
         // (it should be since a user is authenticated)
         if ( this.Request.Cookies[FormsAuthentication.FormsCookieName] != null )
         {
             string LinkParams =
                 string.Format(
                     "SIGMA={0}",
                     this.Request.Cookies[FormsAuthentication.FormsCookieName].Value );
 
             // pass the authentication cookie to the ClickOnce application
             return this.ResolveUrl( string.Format( "~/co/WindowsApplication1.application?{0}", LinkParams ) );
         }
         else
             return string.Empty;
     }
 }

Second, create a web service for the ClickOnce application and secure each method with declarative check against not authenticated calls:



[WebService( Namespace = "http://tempuri.org/" )]
[WebServiceBinding( ConformsTo = WsiProfiles.BasicProfile1_1 )]
[ToolboxItem( false )]
public class Service1 : System.Web.Services.WebService
{
 
    [WebMethod]
    // this is crucial to secure the call 
    [PrincipalPermission( SecurityAction.Demand, Authenticated = true )]
    public string HelloWorld()
    {
        // dummy method so return anything, for example the user name
        if ( this.User != null )
            return this.User.Identity.Name;
        else
            return string.Empty;
    }
}

Third, in your ClickOnce application invoked from the web page read the form authentication cookie parameter:



public static Dictionary<string, string> UriArgs()
{
    Dictionary<string, string> nameValueTable = new Dictionary<string, string>();
    if ( System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed )
    {
        string url = 
          System.Deployment.Application.ApplicationDeployment.CurrentDeployment.ActivationUri.AbsoluteUri;
        string queryString = ( new Uri( url ) ).Query;
        if ( queryString != "" )
        {
            string[] nameValuePairs = queryString.Split( '&' );
            bool firstVar = true;
            foreach ( string pair in nameValuePairs )
            {
                string[] vars = pair.Split( '=' );
                if ( firstVar )
                {
                    firstVar = false;
                    if ( vars[0].Contains( "?" ) )
                    {
                        vars = new string[] { vars[0].Trim( '?' ), vars[1] };
                    }
                }
                if ( !nameValueTable.ContainsKey( vars[0] ) )
                {
                    nameValueTable.Add( vars[0], vars[1] );
                }
            }
        }
    }
 
    return ( nameValueTable );
}

Fourth, pass the authentication cookie (available now to the ClickOnce application) to each Web Service call:



// create web service proxy and pass authenticaiton cookie to it
TheService.Service1 serv
 {
     get
     {
         TheService.Service1 s = new WindowsApplication1.TheService.Service1();
 
         // demonstration only
         s.Url = textBox1.Text;
 
         // initialize the proxy's cookie container
         s.CookieContainer = new System.Net.CookieContainer();
 
         // pass anything available in ClickOnce uri parameters to the web service call
         // as cookie
 
         // remember that forms authentication cookie will be passed here since
         // its name and value were passed as uri parameters by the web page
         // the ClickOnce application had been invoked from 
         foreach ( string CookieName in Program.UriArgs().Keys )
         {
             System.Net.Cookie cookie =
                 new System.Net.Cookie(
                     CookieName,
                     Program.UriArgs()[CookieName],
                     "/",
                     "yourdomain.here" );
             // ciacho z aspnet_sessionid
             s.CookieContainer.Add( cookie );
         }
 
         return s;
     }
 }

And this is it - the identity is shared between the web application and its ClickOnce component. Even if the ClickOnce component is invoked by an unauthenticated user, any web service call from within the ClickOnce component will fail since the application service is protected by the PrincipalPermission attribute.


Download the source code here. Description inside.

8 comments:

Wiktor Zychla said...

there's actually much simpler way to process the query string of a ClickOnce application (UriArgs method):

http://msdn.microsoft.com/en-us/library/ms172242.aspx

Anonymous said...

Great article. Just what I have been looking for.

In step 4, you create a System.Net.Cookie and then add it to the proxy cookies container.

When you create the cookie you supply a hard coded domain. Is there a way to dynamically get that value? I don't set it and I get an error when trying to add it to the proxy cookie container.

Thanks again.

Wiktor Zychla said...

What I do myself to get a valid domain value is first to set the value manually in the configuration section of the web.config and then pass it from the server to the client as an additional clickonce command line parameter.

This way I can be 100% sure that the cookie value is set properly.

Regards,
Wiktor

Anonymous said...

Wiktor,

I got it to work. My web application is in a different sub domain from the web service that backends the clickonce app. So I made sure that both web configs used the same FormsAuthentication setting plus the same machinekey setting. Then the one missing piece what that I had to set a valid expires date on the cookie in the click once app otherwise it would not flow to the web service. It turns out that it did not matter what domain name I set for that cookie, so long as it was not expired and both site used the same keys to decrypt the cookie.

Thanks again. I have been looking for this solution for a long time and you saved me a lot of trouble.

Anonymous said...

Wiktor,

I have my main web pages where the user authenticates and gets a cookie with an expiration of 5 minutes. The cookie is passed to my click once client and it pulls the cookie off of the url and sets it on the proxy to the web service. The client now makes a call to the web service and everything is good. If I have slidingExpiration set up on the web service, how do I reset the cookie on my client proxy so that it won't time out after the first 5 minutes?

Thanks

Wiktor Zychla said...

If the slidingExpiration is on, I belive that the cookie should be updated in the cookie container automatically (however, I cannot confirm it right now). Are you sure that this is not the case?

Anonymous said...

Hi,

thanks for your post but what happens if the web service is not in the ASP.NET project but in another "ASP.NET Web Service" project ? Then, is there a way to share ASP.NET authentication with the Web Service ?

Pierrick

Tim said...

What happens if the user is using Chrome or Firefox?