Writing a Lightweight OpenID 2.0 Implementation in C#

9/27/2010

Before I start this post, I just want to say that there is already a good implementation out there in the form of DotNetOpenAuth and I would recommend using something like that most of the time. That being said, I love to reinvent the wheel and I wanted to learn how OpenID worked (best way to learn is through doing). So I created a simple OpenID relay in about a day (actually it was about five days of trying to understand the spec and one day of coding) and figured I would share with you.

Anyway, for those that don't know, OpenID is a decentralized authentication protocol. It allows you to sign into a website (the relay agent) by using a provider (Yahoo, Google, etc.) to authenticate the user. That's not that great of a description, how about I go over the steps involved and that might explain what we're doing. Generally speaking OpenID can be broken down into six steps:

  1. Get the provider URL from the user
  2. Call the URL and discover the actual OpenID provider URL (usually in the form of an XRDS doc)
  3. An association is created between the website (relaying agent) and the provider
  4. The website then creates a request to authorize the user and redirects the user based on this request to the provider
  5. The user logs in, allows/disallows the data that the website is requesting, and gets sent back.
  6. The website then verifies the data that it receives.
Let's go over these steps, show some code while we're at it, and give a basic example. So let's assume that we have a website and we want to have a button that says "Login using Google" or whomever. Google is the only OpenID provider that we're going to accept in this instance so we only need that one button (note that I'm using Google because they're rather strict on this stuff and easier to screw up on than the others).
 
Google, by default, uses https://www.google.com/accounts/o8/id as their OpenID link for everyone. So step 1 is taken care of for us. However for another provider you may need to accept input by the user. In this case you need to make sure that it is in canonical URL form (http:// at the front and / at the end for basic domain names).
 
So far, no real code needed. We just have a button that they click. The next step is querying the URL (in our example https://www.google.com/accounts/o8/id) to discover the location of the OpenID provider. Most of the time this is going to be an XRDS doc (a Yadis doc) as long as they're using OpenID 2.0 but I have seen some that still use HTML link tags. This is rather simple and would look something like this:
   1: protected string GetServerURL(string ServerURL)
   2: {
   3:     try
   4:     {
   5:         using (WebClient Client = new WebClient())
   6:         {
   7:             string Html = Client.DownloadString(ServerURL);
   8:             Match TempURI = Uri.Match(Html);
   9:             if (TempURI != null)
  10:                 return TempURI.Groups["URI"].Value;
  11:             return "";
  12:         }
  13:     }
  14:     catch { throw; }
  15: }
  16:  
  17: private static readonly Regex Uri = new Regex("<URI>(?<URI>(.*))</URI>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
Note that ServerURL is the URL that the user would have given us. This in turn (most of the time) gives us an XRDS doc (which is just an XML doc) that we then parse out the URI from. This URI should be the location of the OpenID provider. Note though that if the provider is using the HTML link tag instead, then you'll need to parse and find that (in the final version I do that, using some code from Mads Kristensen's code. Note that I wouldn't use much of his code for OpenID as it is rather unsafe and seems to be oriented for OpenID 1.0 instead of 2.0).
 
At this point we know where to make our calls and that leads us to step 3, creating an association... I didn't do this in my code and to be honest you don't have to. You see creating an association is really just using Diffie-Hellman key exchange to generate a secret key such that the provider can sign the information and you can tell whether or not they were the ones that sent it. The benefit of this is you can do this once and just use this key on each call during verification and thus reduce the amount of bandwidth used. However .Net makes it a pain to do as you need a BigInteger class like in Mono, etc. and none of these things are in .Net by default. There are a number of implementations out there for Diffie-Hellman but I'm trying to write this code myself and like I said, it isn't required. So we're skipping step 3.
 
This leads us to step 4, creating our request. A OpenID authorization request is nothing more than a GET call to the OpenID provider that we got in step 2 with a query string holding our request. Basically it would end up looking something like this:
 
https://www.google.com/accounts/o8/id
?openid.ns=http://specs.openid.net/auth/2.0
&openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select
&openid.identity=http://specs.openid.net/auth/2.0/identifier_select
&openid.return_to=http://www.MYWEBSITE.com/
&openid.mode=checkid_setup
 
Like I said, just a query string. The openid.ns part lets it know that we're making a OpenID 2.0 request. The claimed_id and identity have to be the http://specs.openid.net/auth/2.0/identifier_select to let it know that we don't know what the person's ID is and need it. The return_to part is the location that the user will be sent back to after they log in to the OpenID provider. And the mode is set to checkid_setup. This allows the user to interact with the OpenID provider (in our case Google) directly. Trust me when I say that this is much simpler than using checkid_immediate (where you have to collect and send the login info yourself). Note that this needs to be url encoded as you're going to be redirecting the user to this location.
 
So at this point we have our request and as such redirect the user to the location. Once there the user logs in, allows/disallows the information that we want, etc. and gets set back to us by the provider. That's all there is to step 5 and as such not much that we need to do. In step 6 though we've got the user and along with them we've been given a bunch of info in the form of a query string. In our example the user was sent back to http://www.MYWEBSITE.com but appended to it will be a query string that looks like this:
 
http://www.MYWEBSITE.com/
?openid.ns=http://specs.openid.net/auth/2.0
&openid.mode=id_res
&openid.op_endpoint=https://www.google.com/accounts/o8/ud
&openid.response_nonce=2008-09-18T04:14:41Zt6shNlcz-MBdaw
&openid.return_to=http://www.MYWEBSITE.com
&openid.assoc_handle=ABSmpf6DNMw
&openid.signed=op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle
&openid.sig=s/gfiWSVLBQcmkjvsKvbIShczH2NOisjzBLZOsfizkI=
&openid.identity=https://www.google.com/accounts/o8/id/id=ACyQatixLeLODscWvwqsCXWQ2sa3RRaBhaKTkcsvUElI6tNHIQ1_egX_wt1x3fAY983DpW4UQV_U
&openid.claimed_id=https://www.google.com/accounts/o8/id/id=ACyQatixLeLODscWvwqsCXWQ2sa3RRaBhaKTkcsvUElI6tNHIQ1_egX_wt1x3fAY983DpW4UQV_U
 
Note that I'm getting this from Google's own OpenID examples. Anyway, the returned query string has a couple of values there. The only ones that we care about directly are identity and mode. The identity is the user's ID in Google (basically what we would use as their username). It's signed and thus not readable, but we really don't need to know what it says. The mode on the other hand is important because it actually tells us if the request went OK or not. In the example above, that's a successful request. But if the openid.mode were set to cancel, we wouldn't have the identifier, etc. as it was a failed request.
 
Once we figure out if we have a successful or unsuccessful request, we have to verify the data. This is done in a couple of steps:
  1. Make sure the return_to part of the request is actually where the user was sent
  2. Make sure that if you asked for a bit of data that you actually received it
  3. In the response you'll notice the field response_nonce. Make sure that you haven't received this before and if you have, it's a bad response.
  4. Verify the signature is valid for all fields that are signed.
Note that, that's the verification sequence that the OpenID spec states. To be honest, there are a couple more steps that you'll probably want to take but that's all that are needed. Anyway, steps 1 through 3 are rather simple, it's step 4 that is a bit trickier. If we had actually done step 3 (creating an association), we would at this point use the encryption to check the signed items against the key. This is referred to as "smart mode". However we didn't do step 3 so we're left with "dumb mode". In dumb mode, we have to make an HTTP POST request against the OpenID provider. This is done by simply switching the mode portion of the query string to check_authentication and send the request to the OpenID provider (in our case https://www.google.com/accounts/o8/id). The provider in turn verifies that it was the one that sent the data and sends us back a series of value pairs in the format of Key:Value. The only one we care about is is_valid, which should be set to true. If it is, then we're good to go.
 
Anyway, that's all there is to actually doing OpenID from a step by step point of view, so let's look at some code:
   1: /*
   2: Copyright (c) 2010 <a href="http://www.gutgames.com">James Craig</a>
   3: 
   4: Permission is hereby granted, free of charge, to any person obtaining a copy
   5: of this software and associated documentation files (the "Software"), to deal
   6: in the Software without restriction, including without limitation the rights
   7: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   8: copies of the Software, and to permit persons to whom the Software is
   9: furnished to do so, subject to the following conditions:
  10: 
  11: The above copyright notice and this permission notice shall be included in
  12: all copies or substantial portions of the Software.
  13: 
  14: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20: THE SOFTWARE.*/
  21:  
  22: #region Usings
  23: using System;
  24: using System.Collections.Generic;
  25: using System.Linq;
  26: using System.Text;
  27: using System.Collections.Specialized;
  28: using System.Web;
  29: using System.Text.RegularExpressions;
  30: using System.Net;
  31: using System.Xml;
  32: using Utilities.Web.OpenID.Extensions.Enums;
  33: using Utilities.DataTypes;
  34: using Utilities.Web.OpenID.Extensions;
  35: using Utilities.Web.OpenID.Extensions.Interfaces;
  36: #endregion
  37:  
  38: namespace Utilities.Web.OpenID
  39: {
  40:     /// <summary>
  41:     /// OpenID helper
  42:     /// </summary>
  43:     public class OpenID
  44:     {
  45:         #region Constructor
  46:  
  47:         /// <summary>
  48:         /// Constructor
  49:         /// </summary>
  50:         public OpenID()
  51:         {
  52:             try
  53:             {
  54:                 Extensions = new System.Collections.Generic.List<IExtension>();
  55:                 if (NonceList == null)
  56:                     NonceList = new System.Collections.Generic.List<string>();
  57:             }
  58:             catch { throw; }
  59:         }
  60:  
  61:         #endregion
  62:  
  63:         #region Public Properties
  64:  
  65:         /// <summary>
  66:         /// Extensions list
  67:         /// </summary>
  68:         public System.Collections.Generic.List<IExtension> Extensions { get; set; }
  69:  
  70:         /// <summary>
  71:         /// Redirect URL
  72:         /// </summary>
  73:         public string RedirectURL { get; set; }
  74:  
  75:         /// <summary>
  76:         /// Server URL
  77:         /// </summary>
  78:         public string ServerURL { get; set; }
  79:  
  80:         /// <summary>
  81:         /// Endpoint URL
  82:         /// </summary>
  83:         protected string EndpointURL { get; set; }
  84:  
  85:         #endregion
  86:  
  87:         #region Public Functions
  88:  
  89:         /// <summary>
  90:         /// Generates login URL
  91:         /// </summary>
  92:         /// <returns>The login URL based on request</returns>
  93:         public string GenerateLoginURL()
  94:         {
  95:             try
  96:             {
  97:                 EndpointURL = GetServerURL();
  98:                 return CreateLoginRedirectUrl();
  99:             }
 100:             catch { throw; }
 101:         }
 102:  
 103:         /// <summary>
 104:         /// Gets attributes returned by the provider
 105:         /// </summary>
 106:         /// <param name="URL">URL</param>
 107:         /// <returns></returns>
 108:         public System.Collections.Generic.List<Pair<string, string>> GetAttributes(string URL)
 109:         {
 110:             try
 111:             {
 112:                 System.Collections.Generic.List<Pair<string, string>> Pairs = new System.Collections.Generic.List<Pair<string, string>>();
 113:                 MatchCollection Matches = Keys.Matches(URL);
 114:                 foreach (Match Match in Matches)
 115:                 {
 116:                     Pairs.Add(new Pair<string, string>(Match.Groups["Left"].Value, Match.Groups["Right"].Value));
 117:                 }
 118:                 if (Verify(URL, Pairs))
 119:                     return Pairs;
 120:                 return null;
 121:             }
 122:             catch { throw; }
 123:         }
 124:  
 125:         #endregion
 126:  
 127:         #region Protected Functions
 128:  
 129:         /// <summary>
 130:         /// Creates a redirect URL for login requests
 131:         /// </summary>
 132:         /// <returns>A redirect URL</returns>
 133:         protected string CreateLoginRedirectUrl()
 134:         {
 135:             try
 136:             {
 137:                 System.Collections.Generic.List<Pair<string, string>> Pairs = new System.Collections.Generic.List<Pair<string, string>>();
 138:                 Pairs.Add(new Pair<string, string>("openid.ns", HttpUtility.UrlEncode("http://specs.openid.net/auth/2.0")));
 139:                 Pairs.Add(new Pair<string, string>("openid.mode", "checkid_setup"));
 140:                 Pairs.Add(new Pair<string, string>("openid.identity", HttpUtility.UrlEncode("http://specs.openid.net/auth/2.0/identifier_select")));
 141:                 Pairs.Add(new Pair<string, string>("openid.claimed_id", HttpUtility.UrlEncode("http://specs.openid.net/auth/2.0/identifier_select")));
 142:                 Pairs.Add(new Pair<string, string>("openid.return_to", HttpUtility.UrlEncode(RedirectURL)));
 143:                 foreach (IExtension Extension in Extensions)
 144:                 {
 145:                     Pairs.AddRange(Extension.GenerateURLAttributes());
 146:                 }
 147:                 StringBuilder Builder = new StringBuilder();
 148:                 Builder.Append(EndpointURL);
 149:                 string Splitter = "?";
 150:                 foreach (Pair<string, string> Pair in Pairs)
 151:                 {
 152:                     Builder.Append(Splitter).Append(Pair.Left).Append("=").Append(Pair.Right);
 153:                     Splitter = "&";
 154:                 }
 155:                 return Builder.ToString();
 156:             }
 157:             catch { throw; }
 158:         }
 159:  
 160:         /// <summary>
 161:         /// Get server endpoint URL
 162:         /// </summary>
 163:         /// <returns>Endpoint URL</returns>
 164:         protected string GetServerURL()
 165:         {
 166:             try
 167:             {
 168:                 if (!string.IsNullOrEmpty(EndpointURL))
 169:                     return EndpointURL;
 170:                 using (WebClient Client = new WebClient())
 171:                 {
 172:                     string Html = Client.DownloadString(ServerURL);
 173:  
 174:                     foreach (Match Match in Links.Matches(Html))
 175:                     {
 176:                         string Temp = GetLink(Match);
 177:                         if (!string.IsNullOrEmpty(Temp))
 178:                             return Temp;
 179:                     }
 180:                     Match TempURI = Uri.Match(Html);
 181:                     if (TempURI != null)
 182:                         return TempURI.Groups["URI"].Value;
 183:                     return "";
 184:                 }
 185:             }
 186:             catch { throw; }
 187:         }
 188:  
 189:         /// <summary>
 190:         /// Gets the link to the end point
 191:         /// </summary>
 192:         /// <param name="Match">Match found</param>
 193:         /// <returns>The end point or empty string</returns>
 194:         protected static string GetLink(Match Match)
 195:         {
 196:             try
 197:             {
 198:                 if (Match.Value.IndexOf("openid.server") > 0)
 199:                 {
 200:                     Match = Href.Match(Match.Value);
 201:                     if (Match.Success)
 202:                     {
 203:                         return Match.Groups[1].Value;
 204:                     }
 205:                 }
 206:                 return "";
 207:             }
 208:             catch { throw; }
 209:         }
 210:  
 211:         /// <summary>
 212:         /// Verifies the request
 213:         /// </summary>
 214:         /// <param name="URL">URL returned</param>
 215:         /// <param name="Pairs">Individual attribute pairs</param>
 216:         /// <returns>true if it is valid, false otherwise</returns>
 217:         protected bool Verify(string URL, System.Collections.Generic.List<Pair<string, string>> Pairs)
 218:         {
 219:             try
 220:             {
 221:                 Pair<string, string> Mode = Pairs.Find(x => x.Left.Equals("openid.mode", StringComparison.CurrentCultureIgnoreCase));
 222:                 if (Mode.Right.Equals("cancel", StringComparison.CurrentCultureIgnoreCase))
 223:                     return false;
 224:                 Pair<string, string> ReturnTo = Pairs.Find(x => x.Left.Equals("openid.return_to", StringComparison.CurrentCultureIgnoreCase));
 225:                 if (ReturnTo == null)
 226:                     return false;
 227:                 Match PartialUrl = Regex.Match(URL, "^" + ReturnTo.Right, RegexOptions.IgnoreCase);
 228:                 if (!PartialUrl.Success)
 229:                     return false;
 230:                 if (Pairs.Find(x => x.Left.Equals("openid.identity", StringComparison.CurrentCultureIgnoreCase)) == null
 231:                     || Pairs.Find(x => x.Left.Equals("openid.claimed_id", StringComparison.CurrentCultureIgnoreCase)) == null)
 232:                     return false;
 233:                 foreach (IExtension Extension in Extensions)
 234:                 {
 235:                     if (!Extension.Verify(URL, Pairs))
 236:                         return false;
 237:                 }
 238:                 Pair<string, string> Nonce = Pairs.Find(x => x.Left.Equals("openid.response_nonce", StringComparison.CurrentCultureIgnoreCase));
 239:                 if (Nonce == null)
 240:                     return false;
 241:                 string CurrentNonce = Nonce.Right;
 242:                 foreach (string TempNonce in NonceList)
 243:                 {
 244:                     if (CurrentNonce == TempNonce)
 245:                         return false;
 246:                 }
 247:                 NonceList.Add(CurrentNonce);
 248:                 string VerificationURL = GenerateVerificationUrl(Pairs);
 249:                 using (WebClient Client = new WebClient())
 250:                 {
 251:                     string Html = Client.DownloadString(VerificationURL);
 252:                     if (!Html.Contains("is_valid:true"))
 253:                         return false;
 254:                 }
 255:                 return true;
 256:             }
 257:             catch { throw; }
 258:         }
 259:  
 260:         /// <summary>
 261:         /// Generates a verification URL
 262:         /// </summary>
 263:         /// <param name="Pairs">The individual attribute pairs</param>
 264:         /// <returns>The appropriate verification URL</returns>
 265:         protected string GenerateVerificationUrl(System.Collections.Generic.List<Pair<string, string>> Pairs)
 266:         {
 267:             try
 268:             {
 269:                 Pairs.Find(x => x.Left.Equals("openid.mode", StringComparison.CurrentCultureIgnoreCase)).Right = "check_authentication";
 270:                 StringBuilder Builder = new StringBuilder();
 271:                 Builder.Append(Pairs.Find(x => x.Left.Equals("openid.op_endpoint", StringComparison.CurrentCultureIgnoreCase)).Right);
 272:                 string Splitter = "?";
 273:                 foreach (Pair<string, string> Pair in Pairs)
 274:                 {
 275:                     Builder.Append(Splitter).Append(Pair.Left).Append("=").Append(Pair.Right);
 276:                     Splitter = "&";
 277:                 }
 278:                 return Builder.ToString();
 279:             }
 280:             catch { throw; }
 281:         }
 282:  
 283:         #endregion
 284:  
 285:         #region Static Properties
 286:  
 287:         private static readonly Regex Links = new Regex(@"<link[^>]*/?>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 288:         private static readonly Regex Href = new Regex("href\\s*=\\s*(?:\"(?<1>[^\"]*)\"|(?<1>\\S+))", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 289:         private static readonly Regex Uri = new Regex("<URI>(?<URI>(.*))</URI>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 290:         private static readonly Regex Keys = new Regex("[?&](?<Left>[^=]*)=(?<Right>[^&]*)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 291:         private static readonly Regex GetUrl = new Regex(@"^(?<URL>.*)?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 292:  
 293:         /// <summary>
 294:         /// List of previously received Nonce
 295:         /// </summary>
 296:         public static System.Collections.Generic.List<string> NonceList { get; set; }
 297:  
 298:         #endregion
 299:     }
 300: }
The code above is very basic and is to be used as a base class. It allows you to generate a login request url and parses the response, verifying it, and returns a list of the attributes it finds. It uses the Pair class from my utility library but note that it is simply holder that has a Left and Right property (Left being our Key and Right being our Value).
 
You may also notice that there is a property called Extensions. One of the nice things about OpenID is the fact that it allows for extensions. One of the most widely supported is Attribute Exchange. This allows you to query the OpenID provider for information about the user (email address, name, etc.). As such I created a basic interface and class for Attribute Exchange (however you could extend this further with your own extensions):
   1: /*
   2: Copyright (c) 2010 <a href="http://www.gutgames.com">James Craig</a>
   3: 
   4: Permission is hereby granted, free of charge, to any person obtaining a copy
   5: of this software and associated documentation files (the "Software"), to deal
   6: in the Software without restriction, including without limitation the rights
   7: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   8: copies of the Software, and to permit persons to whom the Software is
   9: furnished to do so, subject to the following conditions:
  10: 
  11: The above copyright notice and this permission notice shall be included in
  12: all copies or substantial portions of the Software.
  13: 
  14: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20: THE SOFTWARE.*/
  21:  
  22: #region Usings
  23: using System;
  24: using System.Collections.Generic;
  25: using System.Linq;
  26: using System.Text;
  27: using Utilities.DataTypes;
  28: using Utilities.Web.OpenID.Extensions.Enums;
  29: using System.Web;
  30: #endregion
  31:  
  32: namespace Utilities.Web.OpenID.Extensions.Interfaces
  33: {
  34:     /// <summary>
  35:     /// Extension interface
  36:     /// </summary>
  37:     public interface IExtension
  38:     {
  39:         #region Functions
  40:  
  41:         /// <summary>
  42:         /// Generates the attributes in a list of pairs
  43:         /// </summary>
  44:         /// <param name="Required">Required attributes</param>
  45:         /// <returns>A list of attribute pairs</returns>
  46:         System.Collections.Generic.List<Pair<string, string>> GenerateURLAttributes();
  47:  
  48:         /// <summary>
  49:         /// Parses the URL and gets any attribute values passed back
  50:         /// </summary>
  51:         /// <param name="URL">URL</param>
  52:         /// <param name="Pairs">Query string broken down into attribute pairs</param>
  53:         /// <returns>True if it's valid, false otherwise</returns>
  54:         bool Verify(string URL,System.Collections.Generic.List<Pair<string, string>> Pairs);
  55:  
  56:         #endregion
  57:     }
  58: }
  59:  
  60: /*
  61: Copyright (c) 2010 <a href="http://www.gutgames.com">James Craig</a>
  62: 
  63: Permission is hereby granted, free of charge, to any person obtaining a copy
  64: of this software and associated documentation files (the "Software"), to deal
  65: in the Software without restriction, including without limitation the rights
  66: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  67: copies of the Software, and to permit persons to whom the Software is
  68: furnished to do so, subject to the following conditions:
  69: 
  70: The above copyright notice and this permission notice shall be included in
  71: all copies or substantial portions of the Software.
  72: 
  73: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  74: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  75: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  76: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  77: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  78: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  79: THE SOFTWARE.*/
  80:  
  81: #region Usings
  82: using System;
  83: using System.Collections.Generic;
  84: using System.Linq;
  85: using System.Text;
  86: using Utilities.DataTypes;
  87: using Utilities.Web.OpenID.Extensions.Enums;
  88: using System.Web;
  89: using Utilities.Web.OpenID.Extensions.Interfaces;
  90: #endregion
  91:  
  92: namespace Utilities.Web.OpenID.Extensions
  93: {
  94:     /// <summary>
  95:     /// Attribute exchange extension
  96:     /// </summary>
  97:     public class AttributeExchange:IExtension
  98:     {
  99:         #region Constructor
 100:  
 101:         /// <summary>
 102:         /// Constructor
 103:         /// </summary>
 104:         public AttributeExchange()
 105:         {
 106:             Required = Attributes.None;
 107:         }
 108:  
 109:         #endregion
 110:  
 111:         #region Properties
 112:  
 113:         /// <summary>
 114:         /// Required attributes
 115:         /// </summary>
 116:         public Attributes Required { get; set; }
 117:  
 118:         #endregion
 119:  
 120:         #region Functions
 121:  
 122:         /// <summary>
 123:         /// Gets a dictionary with the requested values
 124:         /// </summary>
 125:         /// <param name="Pairs">Returned attribute pairs</param>
 126:         /// <returns>A dictionary with the requested values</returns>
 127:         public Dictionary<Attributes, string> GetValues(System.Collections.Generic.List<Pair<string, string>> Pairs)
 128:         {
 129:             try
 130:             {
 131:                 Dictionary<Attributes, string> ReturnValues = new Dictionary<Attributes, string>();
 132:                 Pair<string, string> Pair = Pairs.Find(x => x.Right == "http://openid.net/srv/ax/1.0");
 133:                 string[] Splitter = { "." };
 134:                 string Extension = Pair.Left.Split(Splitter, StringSplitOptions.None)[2];
 135:  
 136:                 if ((Required & Attributes.Address) == Attributes.Address)
 137:                 {
 138:                     ReturnValues.Add(Attributes.Address, Pairs.Find(x => x.Left == "openid." + Extension + ".value.address").Right);
 139:                 }
 140:                 if ((Required & Attributes.BirthDate) == Attributes.BirthDate)
 141:                 {
 142:                     ReturnValues.Add(Attributes.BirthDate, Pairs.Find(x => x.Left == "openid." + Extension + ".value.birthdate").Right);
 143:                 }
 144:                 if ((Required & Attributes.CompanyName) == Attributes.CompanyName)
 145:                 {
 146:                     ReturnValues.Add(Attributes.CompanyName, Pairs.Find(x => x.Left == "openid." + Extension + ".value.companyname").Right);
 147:                 }
 148:                 if ((Required & Attributes.Country) == Attributes.Country)
 149:                 {
 150:                     ReturnValues.Add(Attributes.Country, Pairs.Find(x => x.Left == "openid." + Extension + ".value.country").Right);
 151:                 }
 152:                 if ((Required & Attributes.Email) == Attributes.Email)
 153:                 {
 154:                     ReturnValues.Add(Attributes.Email, Pairs.Find(x => x.Left == "openid." + Extension + ".value.email").Right);
 155:                 }
 156:                 if ((Required & Attributes.FirstName) == Attributes.FirstName)
 157:                 {
 158:                     ReturnValues.Add(Attributes.FirstName, Pairs.Find(x => x.Left == "openid." + Extension + ".value.firstname").Right);
 159:                 }
 160:                 if ((Required & Attributes.FullName) == Attributes.FullName)
 161:                 {
 162:                     ReturnValues.Add(Attributes.FullName, Pairs.Find(x => x.Left == "openid." + Extension + ".value.fullname").Right);
 163:                 }
 164:                 if ((Required & Attributes.Gender) == Attributes.Gender)
 165:                 {
 166:                     ReturnValues.Add(Attributes.Gender, Pairs.Find(x => x.Left == "openid." + Extension + ".value.gender").Right);
 167:                 }
 168:                 if ((Required & Attributes.JobTitle) == Attributes.JobTitle)
 169:                 {
 170:                     ReturnValues.Add(Attributes.JobTitle, Pairs.Find(x => x.Left == "openid." + Extension + ".value.jobtitle").Right);
 171:                 }
 172:                 if ((Required & Attributes.Language) == Attributes.Language)
 173:                 {
 174:                     ReturnValues.Add(Attributes.Language, Pairs.Find(x => x.Left == "openid." + Extension + ".value.language").Right);
 175:                 }
 176:                 if ((Required & Attributes.LastName) == Attributes.LastName)
 177:                 {
 178:                     ReturnValues.Add(Attributes.LastName, Pairs.Find(x => x.Left == "openid." + Extension + ".value.lastname").Right);
 179:                 }
 180:                 if ((Required & Attributes.Phone) == Attributes.Phone)
 181:                 {
 182:                     ReturnValues.Add(Attributes.Phone, Pairs.Find(x => x.Left == "openid." + Extension + ".value.phone").Right);
 183:                 }
 184:                 if ((Required & Attributes.PostalCode) == Attributes.PostalCode)
 185:                 {
 186:                     ReturnValues.Add(Attributes.PostalCode, Pairs.Find(x => x.Left == "openid." + Extension + ".value.postalcode").Right);
 187:                 }
 188:                 if ((Required & Attributes.TimeZone) == Attributes.TimeZone)
 189:                 {
 190:                     ReturnValues.Add(Attributes.TimeZone, Pairs.Find(x => x.Left == "openid." + Extension + ".value.timezone").Right);
 191:                 }
 192:                 if ((Required & Attributes.UserName) == Attributes.UserName)
 193:                 {
 194:                     ReturnValues.Add(Attributes.UserName, Pairs.Find(x => x.Left == "openid." + Extension + ".value.username").Right);
 195:                 }
 196:                 return ReturnValues;
 197:             }
 198:             catch { throw; }
 199:         }
 200:  
 201:         public bool Verify(string URL, System.Collections.Generic.List<Pair<string, string>> Pairs)
 202:         {
 203:             try
 204:             {
 205:                 Pair<string, string> Pair = Pairs.Find(x => x.Right == "http://openid.net/srv/ax/1.0");
 206:                 if (Pair == null && Required != Attributes.None)
 207:                     return false;
 208:                 else if (Pair == null)
 209:                     return true;
 210:                 string[] Splitter = { "." };
 211:                 string Extension = Pair.Left.Split(Splitter, StringSplitOptions.None)[2];
 212:  
 213:                 if ((Required & Attributes.Address) == Attributes.Address)
 214:                 {
 215:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.address") == null)
 216:                         return false;
 217:                 }
 218:                 if ((Required & Attributes.BirthDate) == Attributes.BirthDate)
 219:                 {
 220:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.birthdate") == null)
 221:                         return false;
 222:                 }
 223:                 if ((Required & Attributes.CompanyName) == Attributes.CompanyName)
 224:                 {
 225:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.companyname") == null)
 226:                         return false;
 227:                 }
 228:                 if ((Required & Attributes.Country) == Attributes.Country)
 229:                 {
 230:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.country") == null)
 231:                         return false;
 232:                 }
 233:                 if ((Required & Attributes.Email) == Attributes.Email)
 234:                 {
 235:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.email") == null)
 236:                         return false;
 237:                 }
 238:                 if ((Required & Attributes.FirstName) == Attributes.FirstName)
 239:                 {
 240:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.firstname") == null)
 241:                         return false;
 242:                 }
 243:                 if ((Required & Attributes.FullName) == Attributes.FullName)
 244:                 {
 245:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.fullname") == null)
 246:                         return false;
 247:                 }
 248:                 if ((Required & Attributes.Gender) == Attributes.Gender)
 249:                 {
 250:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.gender") == null)
 251:                         return false;
 252:                 }
 253:                 if ((Required & Attributes.JobTitle) == Attributes.JobTitle)
 254:                 {
 255:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.jobtitle") == null)
 256:                         return false;
 257:                 }
 258:                 if ((Required & Attributes.Language) == Attributes.Language)
 259:                 {
 260:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.language") == null)
 261:                         return false;
 262:                 }
 263:                 if ((Required & Attributes.LastName) == Attributes.LastName)
 264:                 {
 265:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.lastname") == null)
 266:                         return false;
 267:                 }
 268:                 if ((Required & Attributes.Phone) == Attributes.Phone)
 269:                 {
 270:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.phone") == null)
 271:                         return false;
 272:                 }
 273:                 if ((Required & Attributes.PostalCode) == Attributes.PostalCode)
 274:                 {
 275:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.postalcode") == null)
 276:                         return false;
 277:                 }
 278:                 if ((Required & Attributes.TimeZone) == Attributes.TimeZone)
 279:                 {
 280:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.timezone") == null)
 281:                         return false;
 282:                 }
 283:                 if ((Required & Attributes.UserName) == Attributes.UserName)
 284:                 {
 285:                     if (Pairs.Find(x => x.Left == "openid." + Extension + ".value.username") == null)
 286:                         return false;
 287:                 }
 288:                 return true;
 289:             }
 290:             catch { throw; }
 291:         }
 292:  
 293:         public System.Collections.Generic.List<Pair<string, string>> GenerateURLAttributes()
 294:         {
 295:             try
 296:             {
 297:                 System.Collections.Generic.List<Pair<string, string>> ReturnValues = new System.Collections.Generic.List<Pair<string, string>>();
 298:                 if (Required == Attributes.None)
 299:                     return ReturnValues;
 300:                 ReturnValues.Add(new Pair<string, string>("openid.ns.ax", HttpUtility.UrlEncode("http://openid.net/srv/ax/1.0")));
 301:                 ReturnValues.Add(new Pair<string, string>("openid.ax.mode", "fetch_request"));
 302:                 if ((Required & Attributes.Address) == Attributes.Address)
 303:                 {
 304:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.address", HttpUtility.UrlEncode("http://axschema.org/contact/postalAddress/home")));
 305:                 }
 306:                 if ((Required & Attributes.BirthDate) == Attributes.BirthDate)
 307:                 {
 308:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.birthdate", HttpUtility.UrlEncode("http://axschema.org/birthDate")));
 309:                 }
 310:                 if ((Required & Attributes.CompanyName) == Attributes.CompanyName)
 311:                 {
 312:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.companyname", HttpUtility.UrlEncode("http://axschema.org/company/name")));
 313:                 }
 314:                 if ((Required & Attributes.Country) == Attributes.Country)
 315:                 {
 316:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.country", HttpUtility.UrlEncode("http://axschema.org/contact/country/home")));
 317:                 }
 318:                 if ((Required & Attributes.Email) == Attributes.Email)
 319:                 {
 320:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.email", HttpUtility.UrlEncode("http://axschema.org/contact/email")));
 321:                 }
 322:                 if ((Required & Attributes.FirstName) == Attributes.FirstName)
 323:                 {
 324:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.firstname", HttpUtility.UrlEncode("http://axschema.org/namePerson/first")));
 325:                 }
 326:                 if ((Required & Attributes.FullName) == Attributes.FullName)
 327:                 {
 328:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.fullname", HttpUtility.UrlEncode("http://axschema.org/namePerson")));
 329:                 }
 330:                 if ((Required & Attributes.Gender) == Attributes.Gender)
 331:                 {
 332:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.gender", HttpUtility.UrlEncode("http://axschema.org/person/gender")));
 333:                 }
 334:                 if ((Required & Attributes.JobTitle) == Attributes.JobTitle)
 335:                 {
 336:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.jobtitle", HttpUtility.UrlEncode("http://axschema.org/company/title")));
 337:                 }
 338:                 if ((Required & Attributes.Language) == Attributes.Language)
 339:                 {
 340:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.language", HttpUtility.UrlEncode("http://axschema.org/pref/language")));
 341:                 }
 342:                 if ((Required & Attributes.LastName) == Attributes.LastName)
 343:                 {
 344:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.lastname", HttpUtility.UrlEncode("http://axschema.org/namePerson/last")));
 345:                 }
 346:                 if ((Required & Attributes.Phone) == Attributes.Phone)
 347:                 {
 348:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.phone", HttpUtility.UrlEncode("http://axschema.org/contact/phone/default")));
 349:                 }
 350:                 if ((Required & Attributes.PostalCode) == Attributes.PostalCode)
 351:                 {
 352:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.postalcode", HttpUtility.UrlEncode("http://axschema.org/contact/postalCode/home")));
 353:                 }
 354:                 if ((Required & Attributes.TimeZone) == Attributes.TimeZone)
 355:                 {
 356:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.timezone", HttpUtility.UrlEncode("http://axschema.org/pref/timezone")));
 357:                 }
 358:                 if ((Required & Attributes.UserName) == Attributes.UserName)
 359:                 {
 360:                     ReturnValues.Add(new Pair<string, string>("openid.ax.type.username", HttpUtility.UrlEncode("http://axschema.org/namePerson/friendly")));
 361:                 }
 362:                 ReturnValues.Add(new Pair<string, string>("openid.ax.required", Required.ToString().ToLower().Replace(" ", "")));
 363:                 return ReturnValues;
 364:             }
 365:             catch { throw; }
 366:         }
 367:  
 368:         #endregion
 369:     }
 370: }
The code above is both the interface and the Attribute Exchange code. For the most part, all that's needed is a GenerateURLAttributes function for when you're generating the original request and a Verify function (for verification). With this we can query the OpenID provider for information beyond just "did they sign in?". We can build a basic profile by asking the provider (note that different providers support different things, but usually first name, last name, and email are safe bets). But the list of things that I put in there are the following:
   1: /*
   2: Copyright (c) 2010 <a href="http://www.gutgames.com">James Craig</a>
   3: 
   4: Permission is hereby granted, free of charge, to any person obtaining a copy
   5: of this software and associated documentation files (the "Software"), to deal
   6: in the Software without restriction, including without limitation the rights
   7: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   8: copies of the Software, and to permit persons to whom the Software is
   9: furnished to do so, subject to the following conditions:
  10: 
  11: The above copyright notice and this permission notice shall be included in
  12: all copies or substantial portions of the Software.
  13: 
  14: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20: THE SOFTWARE.*/
  21:  
  22: #region Usings
  23: using System;
  24: using System.Collections.Generic;
  25: using System.Linq;
  26: using System.Text;
  27: #endregion
  28:  
  29: namespace Utilities.Web.OpenID.Extensions.Enums
  30: {
  31:     /// <summary>
  32:     /// Attribute exchange attributes (plus ID, which is used for returning the ID to the user)
  33:     /// </summary>
  34:     [Flags]
  35:     public enum Attributes
  36:     {
  37:         None=0,
  38:         Email=1,
  39:         UserName=2,
  40:         FullName=4,
  41:         FirstName=8,
  42:         LastName=16,
  43:         CompanyName=32,
  44:         JobTitle=64,
  45:         BirthDate=128,
  46:         Phone=256,
  47:         Gender=512,
  48:         Address=1024,
  49:         PostalCode=2048,
  50:         Country=4096,
  51:         Language=8192,
  52:         TimeZone=16384,
  53:         ID=32768
  54:     }
  55: }
Note that it has the Flag attribute and thus can be | together so we can ask for multiple items (ex. Attributes.Email | Attributes.FirstName | Attributes.LastName|Attributes.Country would be asking for email, first name, last name, and country). So now all we need to do is set up a class that inherits from OpenID and we're good to go:
   1: /*
   2: Copyright (c) 2010 <a href="http://www.gutgames.com">James Craig</a>
   3: 
   4: Permission is hereby granted, free of charge, to any person obtaining a copy
   5: of this software and associated documentation files (the "Software"), to deal
   6: in the Software without restriction, including without limitation the rights
   7: to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   8: copies of the Software, and to permit persons to whom the Software is
   9: furnished to do so, subject to the following conditions:
  10: 
  11: The above copyright notice and this permission notice shall be included in
  12: all copies or substantial portions of the Software.
  13: 
  14: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20: THE SOFTWARE.*/
  21:  
  22: #region Usings
  23: using System;
  24: using System.Collections.Generic;
  25: using System.Linq;
  26: using System.Text;
  27: using System.Collections.Specialized;
  28: using System.Web;
  29: using System.Text.RegularExpressions;
  30: using System.Net;
  31: using System.Xml;
  32: using Utilities.Web.OpenID.Extensions.Enums;
  33: using Utilities.DataTypes;
  34: using Utilities.Web.OpenID.Extensions;
  35: using Utilities.Web.OpenID.Extensions.Interfaces;
  36: #endregion
  37:  
  38: namespace Utilities.Web.OpenID
  39: {
  40:     /// <summary>
  41:     /// Generic OpenID provider
  42:     /// </summary>
  43:     public class GenericProvider:OpenID
  44:     {
  45:         #region Constructor
  46:  
  47:         /// <summary>
  48:         /// Constructor
  49:         /// </summary>
  50:         public GenericProvider()
  51:             : base()
  52:         {
  53:         }
  54:  
  55:         #endregion
  56:  
  57:         #region Functions
  58:  
  59:         /// <summary>
  60:         /// Generates login URL
  61:         /// </summary>
  62:         /// <returns>The login URL based on request</returns>
  63:         public string GenerateLoginURL(string Redirect,string Server,Attributes Required)
  64:         {
  65:             try
  66:             {
  67:                 this.RedirectURL = Redirect;
  68:                 this.ServerURL = Server;
  69:                 foreach (AttributeExchange Extension in Extensions.OfType<AttributeExchange>())
  70:                 {
  71:                     Extension.Required = Required;
  72:                 }
  73:                 return GenerateLoginURL();
  74:             }
  75:             catch { throw; }
  76:         }
  77:  
  78:         /// <summary>
  79:         /// Gets a list of attributes based on what was requested
  80:         /// </summary>
  81:         /// <param name="URL">The response URL from the provider</param>
  82:         /// <param name="Required">Attributes that are required</param>
  83:         /// <returns>A list of attributes based on what was requested or an exception if the login failed</returns>
  84:         public Dictionary<Attributes, string> GetRequestedAttributes(string URL, Attributes Required)
  85:         {
  86:             try
  87:             {
  88:                 foreach (AttributeExchange Extension in Extensions.OfType<AttributeExchange>())
  89:                 {
  90:                     Extension.Required = Required;
  91:                 }
  92:                 System.Collections.Generic.List<Pair<string, string>> AttributesList = this.GetAttributes(URL);
  93:                 if (AttributesList == null)
  94:                     throw new Exception("The information requested was not received");
  95:                 Dictionary<Attributes, string> FinalValues = new Dictionary<Attributes, string>();
  96:                 foreach (AttributeExchange Extension in Extensions.OfType<AttributeExchange>())
  97:                 {
  98:                     FinalValues=Extension.GetValues(AttributesList);
  99:                 }
 100:                 Pair<string, string> ID = AttributesList.Find(x => x.Left.Equals("openid.claimed_id", StringComparison.CurrentCultureIgnoreCase));
 101:                 FinalValues.Add(Attributes.ID, ID.Right);
 102:                 return FinalValues;
 103:             }
 104:             catch { throw; }
 105:         }
 106:         
 107:         #endregion
 108:     }
 109: }
That's it. With this class (GenericProvider), we can create our request URL (asking for information from the provider), have the user log in, get the response, and parse it out such that we have a nice Dictionary holding the information that we want (with the Attributes.ID entry being the user's identity field in the response). That's all there is to it. I've added this to my utility library and you can get to it in the repository here. And any updates/changes in the future will be available from the CodePlex repository as well. Anyway, I hope you learned a bit about OpenID in this post and perhaps will lead you to build a more robust set of classes than what I have here. But as a learning experience this wasn't bad and gives me much more confidence when it comes to using OpenID in my own projects. So take a look, leave feedback, and happy coding.


Comments