Single Sign-on using Facebook javascript library, Part 2
As I mentioned in my first article about single sign-on using the Facebook javascript library, you may encounter problems with certain clients. In my case it was a specific Safari browser which caused the Facebook javascript to repeatedly trigger the sessionChange event, which in turn caused an infinite redirect loop since the typical/example case includes refreshing the page after the session changes. Also, the C# Facebook SDK failed to pick up the Facebook session, making the ASP.NET membership user integration impossible.
Problem description
It seemed the Facebook javascript was unable to set the cookie used to store the Facebook session. This might very well be because the cookies are essentially cross-domain as they are set by the Facebook javascript which is loaded from Facebook and not from the same host as the page. Most browsers have a security feature which, when enabled, blocks these kinds of third party cookies. However, when trying to reproduce this by enabling this feature in Firefox I didn’t get the same beahviour as with the Safari client.
And even with third party cookies enabled in Safari it behaved badly. Since I have only one Mac to test with I don’t know if the issue is specific to Safari on OS X, a verison of Safari or just this particular client. Googling the problems generate a number of hits but not the amount you would expect if this was a “global” Safari issue. But I still had the need to work around the issue.
The idea
To be honest, I don’t really know what is causing the problem (I could just as well me missing some obvious thing), but I had an idea how to fix it. In the initialization of the Facebook javascript API it is possible to pass in an existing Facebook session as a JSON object. That will cause the script to use that session rather than trying to read it from the cookie or performing a sign-in to Facebook and set the cookie. So if I could find a different way to grab and store the session I should be able to get around any issues relating to third party cookies.
I noted that the problematic client had no issues registering cookies from the site itself. For example the ASP.NET session cookie was stored without problems. So if I could get the Facebook session object I could store it in the ASP.NET session and then use the initialization to set the Facebook session on every subsequent request. Also, I saw while debugging the javascript that the Facebook session object was available to the javascript right after sign-in. So I could just take the data from the session and post it to my application which would then store it in the ASP.NET session.
Step 1: Update the client script
Starting from the user control I created in the previous article, I added some logic to set the Facebook session from the ASP.NET session, and to post a newly signed-in session’s data to my application. The first time the sessionChange event is raised I don’t do anything special, letting the standard cookie approach do it’s work. However, instead of simply refreshing the page I add a querystring parameter with a refresh counter. So when the page loads again I know that it has been loaded before.
If then, I get the sessionChange event again, I interpret it as a symptom of the previously described problems. I then use the JSON library’s stringify method to serialize the session and post it back to my application and update the counter. The next time the page loads, the server side of the code will hopefully have set the Facebook session in the javascript initalization. If however that fails too (and it “shouldn’t”) I just show an alert with an error message – there’s no point in trying again.
Here’s the updated client script. Note the Session[“FacebookSession”] calls which reads data saved in the next step:
1:
2: <div id="fb-root"></div>
3: <script type='text/javascript'>1:
2: window.fbAsyncInit = function () {3: FB.init(
4: {
5: appId: 'APP_ID_GOES_HERE',6: status: true,7: cookie: true,8: <% if (!String.IsNullOrEmpty(Session["FacebookSession"] as string))9: { %>
10: <%= String.Format("session: JSON.parse('{0}'),", Session["FacebookSession"] as string) %>11: <% } %>
12: xfbml: true13: });
14: FB.Event.subscribe('auth.sessionChange', function (response) {15: if (response.session) {16: var qs = new Array();17: if (window.location.search && (window.location.search.length > 1)) {18: var pairs = window.location.search.substring(1).split('&');19: for (var i = 0; i < pairs.length; i++) {20: var nv = pairs[i].split('=');21: if (nv.length > 1) {22: qs[nv[0]] = decodeURIComponent(nv[1]);
23: }
24: }
25: }
26:
27: var qsName = 'fbchk';28: var divId = 'fb-root';29: var inputName = 'fbsession';30: var failMessage = 'Facebook login failed, can not persist Facebook session';31:
32: var qsValue = qs[qsName];33: if (qsValue) {34: // Page has been reloaded before, should not get another sessionChange35: // Possible cookie issue?36: if (qsValue == '1') {37: var currentUrl = new String(window.location);38: var postUrl = currentUrl.replace(qsName + '=1', qsName + '=2');39: var formId = divId + 'Form';40: var inputId = formId + 'Session';41: $('body').prepend('<form id="' + formId + '"></form>');42: var form = $('#' + formId);43: $(form).attr('method', 'POST');44: $(form).attr('action', postUrl);45: $(form).append('<input type="hidden" name="' + inputName + '" id="' + inputId + '" />');46: $('#' + inputId).attr('value', JSON.stringify(response.session));47: $(form).submit();
48: }
49: else {50: alert(failMessage);
51: }
52:
53: }
54: else {55: // A user has logged in, and a new cookie has been saved56: window.location = window.location + ((qs.length > 0) ? '&' : '?') + qsName + '=1';57: }
58:
59: } else {60: // The user has logged out, and the cookie has been cleared61: }
62: });
63: };
64: (function () {65: var divId = 'fb-root';66: var e = document.createElement('script'); e.async = true;67: e.src = document.location.protocol +
68: '//connect.facebook.net/sv_SE/all.js';69: document.getElementById(divId).appendChild(e);
70: } ());
</script>
Step 2: Update the http module
On the server I want to get the posted value containing the Facebook session, deserialize and use it, and store a serialized version in the ASP.NET session state for use in the client script above. For JSON serializing in .NET I decided to go with the standard serialization classes in the System.Runtime.Serialization.Json namespace which sits in the System.Runtime.Serialization assembly, so you’ll need a reference to that. Further, a class is needed which corresponds to the JSON object:
1: /// <summary>
2: /// Mapping class for json serializing a Facebook session
3: /// </summary>
4: [DataContract]
5: public class FBSession
6: {
7: [DataMember(Name="access_token")]
8: public string AccessToken { get; set; }
9:
10: [DataMember(Name = "base_domain")]
11: public string BaseDomain { get; set; }
12:
13: [DataMember(Name = "expires")]
14: public string Expires { get; set; }
15:
16: [DataMember(Name = "secret")]
17: public string Secret { get; set; }
18:
19: [DataMember(Name = "session_key")]
20: public string SessionKey { get; set; }
21:
22: [DataMember(Name = "sig")]
23: public string Signature { get; set; }
24:
25: [DataMember(Name = "uid")]
26: public string UserId { get; set; }
27:
28: }
Because the session state is not available yet in the Authenticate event I used before I also had to move the integration logic to a handler for the PostAcquireRequestState event. Not all handlers have the session state, so I also check that the current handler implements IRequiresSessionState before reading or writing the session state. Apart from that it is pretty straight forward getting the session from the Session state or the values posted by the javascript, and then performing the same membership user integration as before:
1: using System;
2: using System.Web;
3: using System.Web.Security;
4: using System.Web.Profile;
5: using System.Web.SessionState;
6: using System.Runtime.Serialization;
7: using System.Runtime.Serialization.Json;
8: using EPiServer.Personalization;
9: using Facebook;
10:
11: namespace ACME.Web.Templates.HttpModules
12: {
13: public class FacebookIntegrationFallbackModule : IHttpModule
14: {
15: public void Dispose() { }
16:
17: public void Init(HttpApplication context)
18: {
19: context.PostAcquireRequestState += PostAcquireRequestState;
20: }
21:
22: private void PostAcquireRequestState(object sender, EventArgs e)
23: {
24: var application = sender as HttpApplication;
25:
26: if (application == null || application.Request.IsAuthenticated) return;
27:
28: // Construct fbsession from cookie
29: var facebookApp = new Facebook.FacebookApp();
30:
31: if ((facebookApp.Session == null) && (application.Context.Handler is IRequiresSessionState))
32: {
33: // Get fbsession from Session state with fallback to posted values
34: facebookApp.Session = DeserializeSession(application.Session["FacebookSession"] as string
35: ?? application.Request["FacebookSession"]);
36: }
37:
38: // Save fbsession in Session state
39: if ((facebookApp.Session != null) && (application.Context.Handler is IRequiresSessionState))
40: {
41: application.Session["FacebookSession"] = SerializeSession(facebookApp.Session);
42: }
43:
44: if (UpdateUser(application, facebookApp))
45: {
46: application.Response.Redirect(application.Request.Url.ToString());
47: }
48: }
49:
50: private bool UpdateUser(HttpApplication application, Facebook.FacebookApp facebookApp)
51: {
52: if (facebookApp.Session == null || facebookApp.Session.UserId < 1) return false;
53:
54: // Read user data
55: var userId = facebookApp.Session.UserId;
56: var username = userId.ToString();
57: dynamic me = facebookApp.Get("me");
58: string email = me.email;
59: string firstname = me.first_name;
60: string lastname = me.last_name;
61:
62: // Get user, create if not previously exiting
63: var user = Membership.GetUser(username);
64: if (user == null)
65: {
66: // Password won't be used with FB login, so just set a new random one
67: var password = Membership.GeneratePassword(10, 0);
68: user = Membership.CreateUser(username, password, email ?? username + "@acme.web");
69: }
70:
71: var profile = EPiServerProfile.Get(username);
72:
73: // Make user the ID is set in a profile property
74: var savedId = GetFacebookId(profile);
75: if (savedId != facebookApp.Session.UserId)
76: {
77: SetFacebookId(profile, userId);
78: }
79:
80: if (email != null)
81: {
82: // UPdate email both in membership and profile
83: if (user.Email != email)
84: {
85: user.Email = email;
86: Membership.UpdateUser(user);
87: }
88:
89: if (profile.Email != email)
90: {
91: profile.Email = email;
92: }
93: }
94:
95: if (profile.FirstName != firstname)
96: {
97: profile.FirstName = firstname;
98: }
99:
100: if (profile.LastName != lastname)
101: {
102: profile.LastName = lastname;
103: }
104:
105: profile.Save();
106:
107: FormsAuthentication.SetAuthCookie(user.UserName, false);
108:
109: return true;
110: }
111:
112: private static long GetFacebookId(ProfileBase profile)
113: {
114: var value = profile["FacebookId"];
115: return Convert.ToInt64(value);
116: }
117:
118: private static void SetFacebookId(ProfileBase profile, long id)
119: {
120: profile["FacebookId"] = id;
121: }
122:
123: /// <summary>
124: /// Deserializes a FacebookSession object from a json string
125: /// </summary>
126: /// <param name="sessionString">Json serialized session</param>
127: /// <returns>The FacebookSession object</returns>
128: private FacebookSession DeserializeSession(string sessionString)
129: {
130: if (string.IsNullOrEmpty(sessionString)) return null;
131:
132: var serializer = new DataContractJsonSerializer(typeof(FBSession));
133: var stream = new System.IO.MemoryStream(System.Text.Encoding.Unicode.GetBytes(sessionString));
134: var sessionDTO = serializer.ReadObject(stream) as FBSession;
135:
136: var fbSession = new FacebookSession()
137: {
138: AccessToken = sessionDTO.AccessToken,
139: BaseDomain = sessionDTO.BaseDomain,
140: Expires = Facebook.DateTimeConvertor.FromUnixTime(sessionDTO.Expires),
141: Secret = sessionDTO.Secret,
142: SessionKey = sessionDTO.SessionKey,
143: Signature = sessionDTO.Signature,
144: UserId = long.Parse(sessionDTO.UserId, System.Globalization.CultureInfo.InvariantCulture)
145: };
146:
147: return fbSession;
148: }
149:
150: /// <summary>
151: /// Creates a json serialization of the FacebookSession object
152: /// </summary>
153: /// <param name="session">The object to serialize</param>
154: /// <returns>Json serialization string</returns>
155: private string SerializeSession(Facebook.FacebookSession session)
156: {
157: if (session == null) return null;
158:
159: var sessionDTO = new FBSession()
160: {
161: AccessToken = session.AccessToken,
162: BaseDomain = session.BaseDomain,
163: Expires = session.Expires.ToUnixTime().ToString(System.Globalization.CultureInfo.InvariantCulture),
164: Secret = session.Secret,
165: SessionKey = session.SessionKey,
166: Signature = session.Signature,
167: UserId = session.UserId.ToString()
168: };
169:
170: var serializer = new DataContractJsonSerializer(typeof(FBSession));
171: var stream = new System.IO.MemoryStream();
172: serializer.WriteObject(stream, sessionDTO);
173: stream.Position = 0;
174: var reader = new System.IO.StreamReader(stream);
175: var sessionString = reader.ReadToEnd();
176: return sessionString;
177: }
178: }
179: }
Configuration
Finally, don’t forget to add or change the module reference in web.config if you use this code unedited – the class in this example and in the previous one have different names.
Code
The code above is also available in the code section. This time, too, I have edited the code to fit it more easily into the article, so be warned that it might not build or work without a little edit :)
And even more... Thanks Magnus for sharing