diff --git a/lib/config/contacts.yml b/lib/config/contacts.yml
new file mode 100644
index 0000000..17415cd
--- /dev/null
+++ b/lib/config/contacts.yml
@@ -0,0 +1,10 @@
+windows_live:
+ appid: your_app_id
+ secret: your_app_secret_key
+ security_algorithm: wsignin1.0
+ return_url: http://yourserver.com/your_return_url
+ policy_url: http://yourserver.com/you_policy_url
+
+yahoo:
+ appid: your_app_id
+ secret: your_shared_secret
diff --git a/lib/contacts/windows_live.rb b/lib/contacts/windows_live.rb
new file mode 100644
index 0000000..70200eb
--- /dev/null
+++ b/lib/contacts/windows_live.rb
@@ -0,0 +1,165 @@
+require File.dirname(__FILE__) + '/../contacts'
+$:.unshift File.dirname(__FILE__)
+require File.dirname(__FILE__) + '/../../vendor/windowslivelogin'
+require 'net/https'
+require 'uri'
+require 'rubygems'
+require 'hpricot'
+require 'yaml'
+
+module Contacts
+ # = How I can fetch Windows Live Contacts?
+ # To gain access to a Windows Live user's data in the Live Contacts service,
+ # a third-party developer first must ask the owner for permission. You must
+ # do that through Windows Live Delegated Authentication.
+ #
+ # This library give you access to Windows Live Delegated Authentication System
+ # and Windows Live Contacts API. Just follow the steps below and be happy!
+ #
+ # === Registering your app
+ # First of all, follow the steps in this
+ # page[http://msdn.microsoft.com/en-us/library/cc287659.aspx] to register your
+ # app.
+ #
+ # === Configuring your Windows Live YAML
+ # After registering your app, you will have an *appid*, a secret key and
+ # a return URL. Use their values to fill in the config/contacts.yml file.
+ # The policy URL field inside the YAML config file must contain the URL
+ # of the privacy policy of your Web site for Delegated Authentication.
+ #
+ # === Authenticating your user and fetching his contacts
+ #
+ # wl = Contacts::WindowsLive.new
+ # auth_url = wl.get_authentication_url
+ #
+ # Use that *auth_url* to redirect your user to Windows Live. He will authenticate
+ # there and Windows Live will POST to your return URL. You have to get the
+ # body of that POST, let's call it post_body. (if you're using Rails, you can
+ # get the POST body through request.raw_post, in the context of an action inside
+ # ActionController)
+ #
+ # Now, to fetch his contacts, just do this:
+ #
+ # contacts = wl.contacts(post_body)
+ # #-> [ ['Fitzgerald', 'fubar@gmail.com', 'fubar@example.com'],
+ # ['William Paginate', 'will.paginate@gmail.com'], ...
+ # ]
+ #--
+ # This class has two responsibilities:
+ # 1. Access the Windows Live Contacts API through Delegated Authentication
+ # 2. Import contacts from Windows Live and deliver it inside an Array
+ #
+ class WindowsLive
+ CONFIG_FILE = File.dirname(__FILE__) + '/../config/contacts.yml'
+
+ # Initialize a new WindowsLive object.
+ #
+ # ==== Paramaters
+ # * config_file :: The contacts YAML config file name
+ #--
+ # You can check an example of a config file inside config/ directory
+ #
+ def initialize(config_file=CONFIG_FILE)
+ confs = YAML.load_file(config_file)['windows_live']
+ @wll = WindowsLiveLogin.new(confs['appid'], confs['secret'], confs['security_algorithm'],
+ nil, confs['policy_url'], confs['return_url'])
+ end
+
+
+ # Windows Live Contacts API need to authenticate the user that is giving you
+ # access to his contacts. To do that, you must give him a URL. That method
+ # generates that URL. The user must access that URL, and after he has done
+ # authentication, hi will be redirected to your application.
+ #
+ def get_authentication_url
+ @wll.getConsentUrl("Contacts.Invite")
+ end
+
+ # After the user has been authenticaded, Windows Live Delegated Authencation
+ # Service redirects to your application, through a POST HTTP method. Along
+ # with the POST, Windows Live send to you a Consent that you must process
+ # to access the user's contacts. This method process the Consent
+ # to you.
+ #
+ # ==== Paramaters
+ # * consent :: A string containing the Consent given to you inside
+ # the redirection POST from Windows Live
+ #
+ def process_consent(consent)
+ consent.strip!
+ consent = URI.unescape(consent)
+ @consent_token = @wll.processConsent(consent)
+ end
+
+ # This method return the user's contacts inside an Array in the following
+ # format:
+ #
+ # [
+ # ['Brad Fitzgerald', 'fubar@gmail.com'],
+ # [nil, 'nagios@hotmail.com'],
+ # ['William Paginate', 'will.paginate@yahoo.com'] ...
+ # ]
+ #
+ # ==== Paramaters
+ # * consent :: A string containing the Consent given to you inside
+ # the redirection POST from Windows Live
+ #
+ def contacts(consent)
+ process_consent(consent)
+ contacts_xml = access_live_contacts_api()
+ contacts_list = WindowsLive.parse_xml(contacts_xml)
+ end
+
+ # This method access the Windows Live Contacts API Web Service to get
+ # the XML contacts document
+ #
+ def access_live_contacts_api
+ Net::HTTP.version_1_1
+ http = http = Net::HTTP.new('livecontacts.services.live.com', 443)
+ http.use_ssl = true
+
+ response = nil
+ http.start do |http|
+ request = Net::HTTP::Get.new("/users/@L@#{@consent_token.locationid}/rest/invitationsbyemail", {"Authorization" => "DelegatedToken dt=\"#{@consent_token.delegationtoken}\""})
+ response = http.request(request)
+ end
+
+ return response.body
+ end
+
+ # This method parses the XML Contacts document and returns the contacts
+ # inside an Array
+ #
+ # ==== Paramaters
+ # * xml :: A string containing the XML contacts document
+ #
+ def self.parse_xml(xml)
+ doc = Hpricot::XML(xml)
+
+ contacts = []
+ doc.search('/livecontacts/contacts/contact').each do |contact|
+ email = contact.at('/preferredemail').inner_text
+ email.strip!
+
+ first_name = last_name = nil
+ if first_name = contact.at('/profiles/personal/firstname')
+ first_name = first_name.inner_text.strip
+ end
+
+ if last_name = contact.at('/profiles/personal/lastname')
+ last_name = last_name.inner_text.strip
+ end
+
+ name = nil
+ if !first_name.nil? || !last_name.nil?
+ name = "#{first_name} #{last_name}"
+ name.strip!
+ end
+ contacts.push([name, email])
+ end
+
+ return contacts
+ end
+ end
+
+end
diff --git a/lib/contacts/yahoo.rb b/lib/contacts/yahoo.rb
new file mode 100644
index 0000000..6ef8070
--- /dev/null
+++ b/lib/contacts/yahoo.rb
@@ -0,0 +1,235 @@
+require File.dirname(__FILE__) + '/../contacts'
+require 'md5'
+require 'rubygems'
+require 'hpricot'
+require 'net/https'
+require 'uri'
+require 'json'
+require 'yaml'
+
+module Contacts
+ # = How I can fetch Yahoo Contacts?
+ # To gain access to a Yahoo user's data in the Yahoo Address Book Service,
+ # a third-party developer first must ask the owner for permission. You must
+ # do that through Yahoo Browser Based Authentication (BBAuth).
+ #
+ # This library give you access to Yahoo BBAuth and Yahoo Address Book API.
+ # Just follow the steps below and be happy!
+ #
+ # === Registering your app
+ # First of all, follow the steps in this
+ # page[http://developer.yahoo.com/wsregapp/] to register your app. If you need
+ # some help with that form, you can get it
+ # here[http://developer.yahoo.com/auth/appreg.html]. Just two tips: inside
+ # Required access scopes in that registration form, choose
+ # Yahoo! Address Book with Read Only access. Inside
+ # Authentication method choose Browser Based Authentication.
+ #
+ # === Configuring your Yahoo YAML
+ # After registering your app, you will have an application id and a
+ # shared secret. Use their values to fill in the config/contacts.yml
+ # file.
+ #
+ # === Authenticating your user and fetching his contacts
+ #
+ # yahoo = Contacts::Yahoo.new
+ # auth_url = yahoo.get_authentication_url
+ #
+ # Use that *auth_url* to redirect your user to Yahoo BBAuth. He will authenticate
+ # there and Yahoo will redirect to your application entrypoint URL (that you provided
+ # while registering your app with Yahoo). You have to get the path of that
+ # redirect, let's call it path (if you're using Rails, you can get it through
+ # request.request_uri, in the context of an action inside ActionController)
+ #
+ # Now, to fetch his contacts, just do this:
+ #
+ # contacts = wl.contacts(path)
+ # #-> [ ['Fitzgerald', 'fubar@gmail.com', 'fubar@example.com'],
+ # ['William Paginate', 'will.paginate@gmail.com'], ...
+ # ]
+ #--
+ # This class has two responsibilities:
+ # 1. Access the Yahoo Address Book API through Delegated Authentication
+ # 2. Import contacts from Yahoo Mail and deliver it inside an Array
+ #
+ class Yahoo
+ AUTH_DOMAIN = "https://api.login.yahoo.com"
+ AUTH_PATH = "/WSLogin/V1/wslogin?appid=#appid&ts=#ts"
+ CREDENTIAL_PATH = "/WSLogin/V1/wspwtoken_login?appid=#appid&ts=#ts&token=#token"
+ ADDRESS_BOOK_DOMAIN = "address.yahooapis.com"
+ ADDRESS_BOOK_PATH = "/v1/searchContacts?format=json&fields=name,email&appid=#appid&WSSID=#wssid"
+ CONFIG_FILE = File.dirname(__FILE__) + '/../config/contacts.yml'
+
+ attr_reader :appid, :secret, :token, :wssid, :cookie
+
+ # Initialize a new Yahoo object.
+ #
+ # ==== Paramaters
+ # * config_file :: The contacts YAML config file name
+ #--
+ # You can check an example of a config file inside config/ directory
+ #
+ def initialize(config_file=CONFIG_FILE)
+ confs = YAML.load_file(config_file)['yahoo']
+ @appid = confs['appid']
+ @secret = confs['secret']
+ end
+
+ # Yahoo Address Book API need to authenticate the user that is giving you
+ # access to his contacts. To do that, you must give him a URL. This method
+ # generates that URL. The user must access that URL, and after he has done
+ # authentication, hi will be redirected to your application.
+ #
+ def get_authentication_url
+ path = AUTH_PATH.clone
+ path.sub!(/#appid/, @appid)
+
+ timestamp = Time.now.utc.to_i
+ path.sub!(/#ts/, timestamp.to_s)
+
+ signature = MD5.hexdigest(path + @secret)
+ return AUTH_DOMAIN + "#{path}&sig=#{signature}"
+ end
+
+ # This method return the user's contacts inside an Array in the following
+ # format:
+ #
+ # [
+ # ['Brad Fitzgerald', 'fubar@gmail.com'],
+ # [nil, 'nagios@hotmail.com'],
+ # ['William Paginate', 'will.paginate@yahoo.com'] ...
+ # ]
+ #
+ # ==== Paramaters
+ # * path :: The path of the redirect request that Yahoo sent to you
+ # after authenticating the user
+ #
+ def contacts(path)
+ begin
+ validate_signature(path)
+ credentials = access_user_credentials()
+ parse_credentials(credentials)
+ contacts_json = access_address_book_api()
+ Yahoo.parse_contacts(contacts_json)
+ rescue Exception => e
+ "Error #{e.class}: #{e.message}."
+ end
+ end
+
+ # This method processes and validates the redirect request that Yahoo send to
+ # you. Validation is done to verify that the request was really made by
+ # Yahoo. Processing is done to get the token.
+ #
+ # ==== Paramaters
+ # * path :: The path of the redirect request that Yahoo sent to you
+ # after authenticating the user
+ #
+ def validate_signature(path)
+ path.match(/^(.+)&sig=(\w{32})$/)
+ path_without_sig = $1
+ sig = $2
+
+ if sig == MD5.hexdigest(path_without_sig + @secret)
+ path.match(/token=(.+?)&/)
+ @token = $1
+ return true
+ else
+ raise 'Signature not valid. This request may not have been sent from Yahoo.'
+ end
+ end
+
+ # This method accesses Yahoo to retrieve the user's credentials.
+ #
+ def access_user_credentials
+ url = get_credential_url()
+ uri = URI.parse(url)
+
+ http = http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+
+ response = nil
+ http.start do |http|
+ request = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
+ response = http.request(request)
+ end
+
+ return response.body
+ end
+
+ # This method generates the URL that you must access to get user's
+ # credentials.
+ #
+ def get_credential_url
+ path = CREDENTIAL_PATH.clone
+ path.sub!(/#appid/, @appid)
+
+ path.sub!(/#token/, @token)
+
+ timestamp = Time.now.utc.to_i
+ path.sub!(/#ts/, timestamp.to_s)
+
+ signature = MD5.hexdigest(path + @secret)
+ return AUTH_DOMAIN + "#{path}&sig=#{signature}"
+ end
+
+ # This method parses the user's credentials to generate the WSSID and
+ # Coookie that are needed to give you access to user's address book.
+ #
+ # ==== Paramaters
+ # * xml :: A String containing the user's credentials
+ #
+ def parse_credentials(xml)
+ doc = Hpricot::XML(xml)
+ @wssid = doc.at('/BBAuthTokenLoginResponse/Success/WSSID').inner_text.strip
+ @cookie = doc.at('/BBAuthTokenLoginResponse/Success/Cookie').inner_text.strip
+ end
+
+ # This method accesses the Yahoo Address Book API and retrieves the user's
+ # contacts in JSON.
+ #
+ def access_address_book_api
+ http = http = Net::HTTP.new(ADDRESS_BOOK_DOMAIN, 80)
+
+ response = nil
+ http.start do |http|
+ path = ADDRESS_BOOK_PATH.clone
+ path.sub!(/#appid/, @appid)
+ path.sub!(/#wssid/, @wssid)
+
+ request = Net::HTTP::Get.new(path, {'Cookie' => @cookie})
+ response = http.request(request)
+ end
+
+ return response.body
+ end
+
+ # This method parses the JSON contacts document and returns an array
+ # contaning all the user's contacts.
+ #
+ # ==== Paramaters
+ # * json :: A String the user's contacts ni JSON format
+ #
+ def self.parse_contacts(json)
+ people = JSON.parse(json)
+ contacts = []
+
+ people['contacts'].each do |contact|
+ name = nil
+ email = nil
+ contact['fields'].each do |field|
+ case field['type']
+ when 'email'
+ email = field['data']
+ email.strip!
+ when 'name'
+ name = "#{field['first']} #{field['last']}"
+ name.strip!
+ end
+ end
+ contacts.push([name, email])
+ end
+ return contacts
+ end
+
+ end
+end
diff --git a/spec/feeds/contacts.yml b/spec/feeds/contacts.yml
new file mode 100644
index 0000000..161f280
--- /dev/null
+++ b/spec/feeds/contacts.yml
@@ -0,0 +1,10 @@
+windows_live:
+ appid: your_app_id
+ secret: your_app_secret_key
+ security_algorithm: wsignin1.0
+ return_url: http://yourserver.com/your_return_url
+ policy_url: http://yourserver.com/you_policy_url
+
+yahoo:
+ appid: i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--
+ secret: a34f389cbd135de4618eed5e23409d34450
diff --git a/spec/feeds/wl_contacts.xml b/spec/feeds/wl_contacts.xml
new file mode 100644
index 0000000..72b24b3
--- /dev/null
+++ b/spec/feeds/wl_contacts.xml
@@ -0,0 +1,29 @@
+
+
+
+ hugo@hotmail.com
+
+
+
+
+
+
+ froz@gmail.com
+
+
+
+
+ Rafael
+ Timbo
+
+
+ timbo@hotmail.com
+
+
+
+
+
+ betinho@hotmail.com
+
+
+
diff --git a/spec/feeds/yh_contacts.txt b/spec/feeds/yh_contacts.txt
new file mode 100644
index 0000000..970626d
--- /dev/null
+++ b/spec/feeds/yh_contacts.txt
@@ -0,0 +1,119 @@
+{
+ "type":"search_response",
+ "contacts":[
+ {
+ "type":"contact",
+ "fields":[
+ {
+ "type":"email",
+ "data":"hugo.barauna@gmail.com",
+ "fid":2,
+ "categories":[
+ ]
+ },
+ {
+ "type":"name",
+ "first":"Hugo",
+ "last":"Barauna",
+ "fid":1,
+ "categories":[
+ ]
+ }
+ ],
+ "cid":4,
+ "categories":[
+ ]
+ },
+ {
+ "type":"contact",
+ "fields":[
+ {
+ "type":"email",
+ "data":"nina@hotmail.com",
+ "fid":5,
+ "categories":[
+ ]
+ },
+ {
+ "type":"name",
+ "first":"Nina",
+ "last":"Benchimol",
+ "fid":4,
+ "categories":[
+ ]
+ }
+ ],
+ "cid":5,
+ "categories":[
+ ]
+ },
+ {
+ "type":"contact",
+ "fields":[
+ {
+ "type":"email",
+ "data":"and@yahoo.com",
+ "fid":7,
+ "categories":[
+ ]
+ },
+ {
+ "type":"name",
+ "first":"Andrea",
+ "last":"Dimitri",
+ "fid":6,
+ "categories":[
+ ]
+ }
+ ],
+ "cid":1,
+ "categories":[
+ ]
+ },
+ {
+ "type":"contact",
+ "fields":[
+ {
+ "type":"email",
+ "data":"ricardo@poli.usp.br",
+ "fid":11,
+ "categories":[
+ ]
+ },
+ {
+ "type":"name",
+ "first":"Ricardo",
+ "last":"Fiorelli",
+ "fid":10,
+ "categories":[
+ ]
+ }
+ ],
+ "cid":3,
+ "categories":[
+ ]
+ },
+ {
+ "type":"contact",
+ "fields":[
+ {
+ "type":"email",
+ "data":"pizinha@yahoo.com.br",
+ "fid":14,
+ "categories":[
+ ]
+ },
+ {
+ "type":"name",
+ "first":"Priscila",
+ "fid":13,
+ "categories":[
+ ]
+ }
+ ],
+ "cid":2,
+ "categories":[
+ ]
+ }
+ ]
+}
diff --git a/spec/feeds/yh_credential.xml b/spec/feeds/yh_credential.xml
new file mode 100644
index 0000000..b4db4e2
--- /dev/null
+++ b/spec/feeds/yh_credential.xml
@@ -0,0 +1,28 @@
+
+
+
+
+Y=cdunlEx76ZEeIdWyeJNOegxfy.jkeoULJCnc7Q0Vr8D5P.u.EE2vCa7G2MwBoULuZhvDZuJNqhHwF3v5RJ4dnsWsEDGOjYV1k6snoln3RlQmx0Ggxs0zAYgbaA4BFQk5ieAkpipq19l6GoD_k8IqXRfJN0Q54BbekC_O6Tj3zl2wV3YQK6Mi2MWBQFSBsO26Tw_1yMAF8saflF9EX1fQl4N.1yBr8UXb6LLDiPQmlISq1_c6S6rFbaOhSZMgO78f2iqZmUAk9RmCHrqPJiHEo.mJlxxHaQsuqTMf7rwLEHqK__Gi_bLypGtaslqeWyS0h2J.B5xwRC8snfEs3ct_kLXT3ngP_pK3MeMf2pe1TiJ4JXVciY9br.KJFUgNd4J6rmQsSFj4wPLoMGCETfVc.M8KLiaFHasZqXDyCE7tvd1khAjQ_xLfQKlg1GlBOWmbimQ1FhdHnsVj3svXjEGquRh8JI2sHIQrzoiqAPBf9WFKQcH0t_1dxf4MOH.7gJaYDPEozCW5EcCsYjuHup9xJKxyTddh5pk8yUg5bURzA.TwPalExMKsbv.RWFBhzWKuTp5guNcqjmUHcCoT19_qFENHX41Xf3texAnsDDGj
+
+ tr.jZsW/ulc
+ 3600
+
+
+
+Y=cdunlEx76ZEeIdWyeJNOegxfy.jkeoULJCnc7Q0Vr8D5P.u.EE2vCa7G2MwBoULuZhvDZuJNqhHwF3v5RJ4dnsWsEDGOjYV1k6snoln3RlQmx0Ggxs0zAYgbaA4BFQk5ieAkpipq19l6GoD_k8IqXRfJN0Q54BbekC_O6Tj3zl2wV3YQK6Mi2MWBQFSBsO26Tw_1yMAF8saflF9EX1fQl4N.1yBr8UXb6LLDiPQmlISq1_c6S6rFbaOhSZMgO78f2iqZmUAk9RmCHrqPJiHEo.mJlxxHaQsuqTMf7rwLEHqK__Gi_bLypGtaslqeWyS0h2J.B5xwRC8snfEs3ct_kLXT3ngP_pK3MeMf2pe1TiJ4JXVciY9br.KJFUgNd4J6rmQsSFj4wPLoMGCETfVc.M8KLiaFHasZqXDyCE7tvd1khAjQ_xLfQKlg1GlBOWmbimQ1FhdHnsVj3svXjEGquRh8JI2sHIQrzoiqAPBf9WFKQcH0t_1dxf4MOH.7gJaYDPEozCW5EcCsYjuHup9xJKxyTddh5pk8yUg5bURzA.TwPalExMKsbv.RWFBhzWKuTp5guNcqjmUHcCoT19_qFENHX41Xf3texAnsDDGj
+
+
+ jjmaQ37IkY0JH18hDRbVIbZ0r6BGYrbaQnm-
+
+
+ tr.jZsW/ulc
+
+
+
+
+
+
+
+
diff --git a/spec/windows_live/windows_live_spec.rb b/spec/windows_live/windows_live_spec.rb
new file mode 100644
index 0000000..97a46f6
--- /dev/null
+++ b/spec/windows_live/windows_live_spec.rb
@@ -0,0 +1,30 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+require 'contacts/windows_live'
+
+describe Contacts::WindowsLive do
+
+ before(:each) do
+ @path = Dir.getwd + '/spec/feeds/'
+ @wl = Contacts::WindowsLive.new(@path + 'contacts.yml')
+ end
+
+ it 'parse the XML contacts document' do
+ contacts = Contacts::WindowsLive.parse_xml(contacts_xml)
+ contacts.should == [ [nil, 'froz@gmail.com'],
+ ['Rafael Timbo', 'timbo@hotmail.com'],
+ [nil, 'betinho@hotmail.com']
+ ]
+ end
+
+ it 'should can be initialized by a YAML file' do
+ wll = @wl.instance_variable_get('@wll')
+
+ wll.appid.should == 'your_app_id'
+ wll.securityalgorithm.should == 'wsignin1.0'
+ wll.returnurl.should == 'http://yourserver.com/your_return_url'
+ end
+
+ def contacts_xml
+ File.open(@path + 'wl_contacts.xml', 'r+').read
+ end
+end
diff --git a/spec/yahoo/yahoo_spec.rb b/spec/yahoo/yahoo_spec.rb
new file mode 100644
index 0000000..ad322b6
--- /dev/null
+++ b/spec/yahoo/yahoo_spec.rb
@@ -0,0 +1,77 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+require 'contacts/yahoo'
+
+describe Contacts::Yahoo do
+
+ before(:each) do
+ @path = Dir.getwd + '/spec/feeds/'
+ @yahoo = Contacts::Yahoo.new(@path + 'contacts.yml')
+ end
+
+ it 'should generate an athentication URL' do
+ auth_url = @yahoo.get_authentication_url()
+ auth_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wslogin\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&sig=.*/)
+ end
+
+ it 'should have a simple interface to grab the contacts' do
+ @yahoo.expects(:access_user_credentials).returns(read_file('yh_credential.xml'))
+ @yahoo.expects(:access_address_book_api).returns(read_file('yh_contacts.txt'))
+
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
+
+
+ @yahoo.contacts(redirect_path).should == [ ['Hugo Barauna', 'hugo.barauna@gmail.com'],
+ ['Nina Benchimol', 'nina@hotmail.com'],
+ ['Andrea Dimitri', 'and@yahoo.com'],
+ ['Ricardo Fiorelli', 'ricardo@poli.usp.br'],
+ ['Priscila', 'pizinha@yahoo.com.br']
+ ]
+ end
+
+ it 'should validate yahoo redirect signature' do
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
+
+ @yahoo.validate_signature(redirect_path).should be_true
+ @yahoo.token.should == 'AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-'
+ end
+
+ it 'should detect when the redirect is not valid' do
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=de4fe4ebd50a8075f75dcc23f6aca04f'
+
+ lambda{ @yahoo.validate_signature(redirect_path) }.should raise_error
+ end
+
+ it 'should generate the credential request URL' do
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
+ @yahoo.validate_signature(redirect_path)
+
+ @yahoo.get_credential_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wspwtoken_login\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&token=.*&sig=.*/)
+ end
+
+ it 'should parse the credential XML' do
+ @yahoo.parse_credentials(read_file('yh_credential.xml'))
+
+ @yahoo.wssid.should == 'tr.jZsW/ulc'
+ @yahoo.cookie.should == 'Y=cdunlEx76ZEeIdWyeJNOegxfy.jkeoULJCnc7Q0Vr8D5P.u.EE2vCa7G2MwBoULuZhvDZuJNqhHwF3v5RJ4dnsWsEDGOjYV1k6snoln3RlQmx0Ggxs0zAYgbaA4BFQk5ieAkpipq19l6GoD_k8IqXRfJN0Q54BbekC_O6Tj3zl2wV3YQK6Mi2MWBQFSBsO26Tw_1yMAF8saflF9EX1fQl4N.1yBr8UXb6LLDiPQmlISq1_c6S6rFbaOhSZMgO78f2iqZmUAk9RmCHrqPJiHEo.mJlxxHaQsuqTMf7rwLEHqK__Gi_bLypGtaslqeWyS0h2J.B5xwRC8snfEs3ct_kLXT3ngP_pK3MeMf2pe1TiJ4JXVciY9br.KJFUgNd4J6rmQsSFj4wPLoMGCETfVc.M8KLiaFHasZqXDyCE7tvd1khAjQ_xLfQKlg1GlBOWmbimQ1FhdHnsVj3svXjEGquRh8JI2sHIQrzoiqAPBf9WFKQcH0t_1dxf4MOH.7gJaYDPEozCW5EcCsYjuHup9xJKxyTddh5pk8yUg5bURzA.TwPalExMKsbv.RWFBhzWKuTp5guNcqjmUHcCoT19_qFENHX41Xf3texAnsDDGj'
+ end
+
+ it 'should parse the contacts json response' do
+ json = read_file('yh_contacts.txt')
+
+ Contacts::Yahoo.parse_contacts(json).should == [ ['Hugo Barauna', 'hugo.barauna@gmail.com'],
+ ['Nina Benchimol', 'nina@hotmail.com'],
+ ['Andrea Dimitri', 'and@yahoo.com'],
+ ['Ricardo Fiorelli', 'ricardo@poli.usp.br'],
+ ['Priscila', 'pizinha@yahoo.com.br']
+ ]
+ end
+
+ it 'should can be initialized by a YAML file' do
+ @yahoo.appid.should == 'i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--'
+ @yahoo.secret.should == 'a34f389cbd135de4618eed5e23409d34450'
+ end
+
+ def read_file(file)
+ File.open(@path + file, 'r+').read
+ end
+end
diff --git a/vendor/windowslivelogin.rb b/vendor/windowslivelogin.rb
new file mode 100644
index 0000000..a235295
--- /dev/null
+++ b/vendor/windowslivelogin.rb
@@ -0,0 +1,1151 @@
+#######################################################################
+# This SDK is provide by Microsoft. I just use it for the delegated
+# authentication part. You can find it in http://www.microsoft.com/downloads/details.aspx?FamilyId=24195B4E-6335-4844-A71D-7D395D20E67B&displaylang=en
+#
+# Author: Hugo Baraúna (hugo.barauna@gmail.com)
+#######################################################################
+
+
+#######################################################################
+#######################################################################
+# FILE: windowslivelogin.rb
+#
+# DESCRIPTION: Sample implementation of Web Authentication and
+# Delegated Authentication protocol in Ruby. Also
+# includes trusted sign-in and application verification
+# sample implementations.
+#
+# VERSION: 1.1
+#
+# Copyright (c) 2008 Microsoft Corporation. All Rights Reserved.
+#######################################################################
+
+require 'cgi'
+require 'uri'
+require 'base64'
+require 'openssl'
+require 'net/https'
+require 'rexml/document'
+
+class WindowsLiveLogin
+
+ #####################################################################
+ # Stub implementation for logging errors. If you want to enable
+ # debugging output using the default mechanism, specify true.
+ # By default, debug information will be printed to the standard
+ # error output and should be visible in the web server logs.
+ #####################################################################
+ def setDebug(flag)
+ @debug = flag
+ end
+
+ #####################################################################
+ # Stub implementation for logging errors. By default, this function
+ # does nothing if the debug flag has not been set with setDebug.
+ # Otherwise, it tries to log the error message.
+ #####################################################################
+ def debug(error)
+ return unless @debug
+ return if error.nil? or error.empty?
+ warn("Windows Live ID Authentication SDK #{error}")
+ nil
+ end
+
+ #####################################################################
+ # Stub implementation for handling a fatal error.
+ #####################################################################
+ def fatal(error)
+ debug(error)
+ raise(error)
+ end
+
+ #####################################################################
+ # Initialize the WindowsLiveLogin module with the application ID,
+ # secret key, and security algorithm.
+ #
+ # We recommend that you employ strong measures to protect the
+ # secret key. The secret key should never be exposed to the Web
+ # or other users.
+ #
+ # Be aware that if you do not supply these settings at
+ # initialization time, you may need to set the corresponding
+ # properties manually.
+ #
+ # For Delegated Authentication, you may optionally specify the
+ # privacy policy URL and return URL. If you do not specify these
+ # values here, the default values that you specified when you
+ # registered your application will be used.
+ #
+ # The 'force_delauth_nonprovisioned' flag also indicates whether
+ # your application is registered for Delegated Authentication
+ # (that is, whether it uses an application ID and secret key). We
+ # recommend that your Delegated Authentication application always
+ # be registered for enhanced security and functionality.
+ #####################################################################
+ def initialize(appid=nil, secret=nil, securityalgorithm=nil,
+ force_delauth_nonprovisioned=nil,
+ policyurl=nil, returnurl=nil)
+ self.force_delauth_nonprovisioned = force_delauth_nonprovisioned
+ self.appid = appid if appid
+ self.secret = secret if secret
+ self.securityalgorithm = securityalgorithm if securityalgorithm
+ self.policyurl = policyurl if policyurl
+ self.returnurl = returnurl if returnurl
+ end
+
+ #####################################################################
+ # Initialize the WindowsLiveLogin module from a settings file.
+ #
+ # 'settingsFile' specifies the location of the XML settings file
+ # that contains the application ID, secret key, and security
+ # algorithm. The file is of the following format:
+ #
+ #
+ # APPID
+ # SECRET
+ # wsignin1.0
+ #
+ #
+ # In a Delegated Authentication scenario, you may also specify
+ # 'returnurl' and 'policyurl' in the settings file, as shown in the
+ # Delegated Authentication samples.
+ #
+ # We recommend that you store the WindowsLiveLogin settings file
+ # in an area on your server that cannot be accessed through the
+ # Internet. This file contains important confidential information.
+ #####################################################################
+ def self.initFromXml(settingsFile)
+ o = self.new
+ settings = o.parseSettings(settingsFile)
+
+ o.setDebug(settings['debug'] == 'true')
+ o.force_delauth_nonprovisioned =
+ (settings['force_delauth_nonprovisioned'] == 'true')
+
+ o.appid = settings['appid']
+ o.secret = settings['secret']
+ o.oldsecret = settings['oldsecret']
+ o.oldsecretexpiry = settings['oldsecretexpiry']
+ o.securityalgorithm = settings['securityalgorithm']
+ o.policyurl = settings['policyurl']
+ o.returnurl = settings['returnurl']
+ o.baseurl = settings['baseurl']
+ o.secureurl = settings['secureurl']
+ o.consenturl = settings['consenturl']
+ o
+ end
+
+ #####################################################################
+ # Sets the application ID. Use this method if you did not specify
+ # an application ID at initialization.
+ #####################################################################
+ def appid=(appid)
+ if (appid.nil? or appid.empty?)
+ return if force_delauth_nonprovisioned
+ fatal("Error: appid: Null application ID.")
+ end
+ if (not appid =~ /^\w+$/)
+ fatal("Error: appid: Application ID must be alpha-numeric: " + appid)
+ end
+ @appid = appid
+ end
+
+ #####################################################################
+ # Returns the application ID.
+ #####################################################################
+ def appid
+ if (@appid.nil? or @appid.empty?)
+ fatal("Error: appid: App ID was not set. Aborting.")
+ end
+ @appid
+ end
+
+ #####################################################################
+ # Sets your secret key. Use this method if you did not specify
+ # a secret key at initialization.
+ #####################################################################
+ def secret=(secret)
+ if (secret.nil? or secret.empty?)
+ return if force_delauth_nonprovisioned
+ fatal("Error: secret=: Secret must be non-null.")
+ end
+ if (secret.size < 16)
+ fatal("Error: secret=: Secret must be at least 16 characters.")
+ end
+ @signkey = derive(secret, "SIGNATURE")
+ @cryptkey = derive(secret, "ENCRYPTION")
+ end
+
+ #####################################################################
+ # Sets your old secret key.
+ #
+ # Use this property to set your old secret key if you are in the
+ # process of transitioning to a new secret key. You may need this
+ # property because the Windows Live ID servers can take up to
+ # 24 hours to propagate a new secret key after you have updated
+ # your application settings.
+ #
+ # If an old secret key is specified here and has not expired
+ # (as determined by the oldsecretexpiry setting), it will be used
+ # as a fallback if token decryption fails with the new secret
+ # key.
+ #####################################################################
+ def oldsecret=(secret)
+ return if (secret.nil? or secret.empty?)
+ if (secret.size < 16)
+ fatal("Error: oldsecret=: Secret must be at least 16 characters.")
+ end
+ @oldsignkey = derive(secret, "SIGNATURE")
+ @oldcryptkey = derive(secret, "ENCRYPTION")
+ end
+
+ #####################################################################
+ # Sets the expiry time for your old secret key.
+ #
+ # After this time has passed, the old secret key will no longer be
+ # used even if token decryption fails with the new secret key.
+ #
+ # The old secret expiry time is represented as the number of seconds
+ # elapsed since January 1, 1970.
+ #####################################################################
+ def oldsecretexpiry=(timestamp)
+ return if (timestamp.nil? or timestamp.empty?)
+ timestamp = timestamp.to_i
+ fatal("Error: oldsecretexpiry=: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
+ @oldsecretexpiry = Time.at timestamp
+ end
+
+ #####################################################################
+ # Gets the old secret key expiry time.
+ #####################################################################
+ attr_accessor :oldsecretexpiry
+
+ #####################################################################
+ # Sets or gets the version of the security algorithm being used.
+ #####################################################################
+ attr_accessor :securityalgorithm
+
+ def securityalgorithm
+ if(@securityalgorithm.nil? or @securityalgorithm.empty?)
+ "wsignin1.0"
+ else
+ @securityalgorithm
+ end
+ end
+
+ #####################################################################
+ # Sets a flag that indicates whether Delegated Authentication
+ # is non-provisioned (i.e. does not use an application ID or secret
+ # key).
+ #####################################################################
+ attr_accessor :force_delauth_nonprovisioned
+
+ #####################################################################
+ # Sets the privacy policy URL, to which the Windows Live ID consent
+ # service redirects users to view the privacy policy of your Web
+ # site for Delegated Authentication.
+ #####################################################################
+ def policyurl=(policyurl)
+ if ((policyurl.nil? or policyurl.empty?) and force_delauth_nonprovisioned)
+ fatal("Error: policyurl=: Invalid policy URL specified.")
+ end
+ @policyurl = policyurl
+ end
+
+ #####################################################################
+ # Gets the privacy policy URL for your site.
+ #####################################################################
+ def policyurl
+ if (@policyurl.nil? or @policyurl.empty?)
+ debug("Warning: In the initial release of Del Auth, a Policy URL must be configured in the SDK for both provisioned and non-provisioned scenarios.")
+ raise("Error: policyurl: Policy URL must be set in a Del Auth non-provisioned scenario. Aborting.") if force_delauth_nonprovisioned
+ end
+ @policyurl
+ end
+
+ #####################################################################
+ # Sets the return URL--the URL on your site to which the consent
+ # service redirects users (along with the action, consent token,
+ # and application context) after they have successfully provided
+ # consent information for Delegated Authentication. This value will
+ # override the return URL specified during registration.
+ #####################################################################
+ def returnurl=(returnurl)
+ if ((returnurl.nil? or returnurl.empty?) and force_delauth_nonprovisioned)
+ fatal("Error: returnurl=: Invalid return URL specified.")
+ end
+ @returnurl = returnurl
+ end
+
+
+ #####################################################################
+ # Returns the return URL of your site.
+ #####################################################################
+ def returnurl
+ if ((@returnurl.nil? or @returnurl.empty?) and force_delauth_nonprovisioned)
+ fatal("Error: returnurl: Return URL must be set in a Del Auth non-provisioned scenario. Aborting.")
+ end
+ @returnurl
+ end
+
+ #####################################################################
+ # Sets or gets the base URL to use for the Windows Live Login server. You
+ # should not have to change this property. Furthermore, we recommend
+ # that you use the Sign In control instead of the URL methods
+ # provided here.
+ #####################################################################
+ attr_accessor :baseurl
+
+ def baseurl
+ if(@baseurl.nil? or @baseurl.empty?)
+ "http://login.live.com/"
+ else
+ @baseurl
+ end
+ end
+
+ #####################################################################
+ # Sets or gets the secure (HTTPS) URL to use for the Windows Live Login
+ # server. You should not have to change this property.
+ #####################################################################
+ attr_accessor :secureurl
+
+ def secureurl
+ if(@secureurl.nil? or @secureurl.empty?)
+ "https://login.live.com/"
+ else
+ @secureurl
+ end
+ end
+
+ #####################################################################
+ # Sets or gets the Consent Base URL to use for the Windows Live Consent
+ # server. You should not have to use or change this property directly.
+ #####################################################################
+ attr_accessor :consenturl
+
+ def consenturl
+ if(@consenturl.nil? or @consenturl.empty?)
+ "https://consent.live.com/"
+ else
+ @consenturl
+ end
+ end
+end
+
+#######################################################################
+# Implementation of the basic methods needed for Web Authentication.
+#######################################################################
+class WindowsLiveLogin
+ #####################################################################
+ # Returns the sign-in URL to use for the Windows Live Login server.
+ # We recommend that you use the Sign In control instead.
+ #
+ # If you specify it, 'context' will be returned as-is in the sign-in
+ # response for site-specific use.
+ #####################################################################
+ def getLoginUrl(context=nil, market=nil)
+ url = baseurl + "wlogin.srf?appid=#{appid}"
+ url += "&alg=#{securityalgorithm}"
+ url += "&appctx=#{CGI.escape(context)}" if context
+ url += "&mkt=#{CGI.escape(market)}" if market
+ url
+ end
+
+ #####################################################################
+ # Returns the sign-out URL to use for the Windows Live Login server.
+ # We recommend that you use the Sign In control instead.
+ #####################################################################
+ def getLogoutUrl(market=nil)
+ url = baseurl + "logout.srf?appid=#{appid}"
+ url += "&mkt=#{CGI.escape(market)}" if market
+ url
+ end
+
+ #####################################################################
+ # Holds the user information after a successful sign-in.
+ #
+ # 'timestamp' is the time as obtained from the SSO token.
+ # 'id' is the pairwise unique ID for the user.
+ # 'context' is the application context that was originally passed to
+ # the sign-in request, if any.
+ # 'token' is the encrypted Web Authentication token that contains the
+ # UID. This can be cached in a cookie and the UID can be retrieved by
+ # calling the processToken method.
+ # 'usePersistentCookie?' indicates whether the application is
+ # expected to store the user token in a session or persistent
+ # cookie.
+ #####################################################################
+ class User
+ attr_reader :timestamp, :id, :context, :token
+
+ def usePersistentCookie?
+ @usePersistentCookie
+ end
+
+
+ #####################################################################
+ # Initialize the User with time stamp, userid, flags, context and token.
+ #####################################################################
+ def initialize(timestamp, id, flags, context, token)
+ self.timestamp = timestamp
+ self.id = id
+ self.flags = flags
+ self.context = context
+ self.token = token
+ end
+
+ private
+ attr_writer :timestamp, :id, :flags, :context, :token
+
+ #####################################################################
+ # Sets or gets the Unix timestamp as obtained from the SSO token.
+ #####################################################################
+ def timestamp=(timestamp)
+ raise("Error: User: Null timestamp in token.") unless timestamp
+ timestamp = timestamp.to_i
+ raise("Error: User: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
+ @timestamp = Time.at timestamp
+ end
+
+ #####################################################################
+ # Sets or gets the pairwise unique ID for the user.
+ #####################################################################
+ def id=(id)
+ raise("Error: User: Null id in token.") unless id
+ raise("Error: User: Invalid id: #{id}") unless (id =~ /^\w+$/)
+ @id = id
+ end
+
+ #####################################################################
+ # Sets or gets the usePersistentCookie flag for the user.
+ #####################################################################
+ def flags=(flags)
+ @usePersistentCookie = false
+ if flags
+ @usePersistentCookie = ((flags.to_i % 2) == 1)
+ end
+ end
+ end
+
+ #####################################################################
+ # Processes the sign-in response from the Windows Live sign-in server.
+ #
+ # 'query' contains the preprocessed POST table, such as that
+ # returned by CGI.params or Rails. (The unprocessed POST string
+ # could also be used here but we do not recommend it).
+ #
+ # This method returns a User object on successful sign-in; otherwise
+ # it returns nil.
+ #####################################################################
+ def processLogin(query)
+ query = parse query
+ unless query
+ debug("Error: processLogin: Failed to parse query.")
+ return
+ end
+ action = query['action']
+ unless action == 'login'
+ debug("Warning: processLogin: query action ignored: #{action}.")
+ return
+ end
+ token = query['stoken']
+ context = CGI.unescape(query['appctx']) if query['appctx']
+ processToken(token, context)
+ end
+
+ #####################################################################
+ # Decodes and validates a Web Authentication token. Returns a User
+ # object on success. If a context is passed in, it will be returned
+ # as the context field in the User object.
+ #####################################################################
+ def processToken(token, context=nil)
+ if token.nil? or token.empty?
+ debug("Error: processToken: Null/empty token.")
+ return
+ end
+ stoken = decodeAndValidateToken token
+ stoken = parse stoken
+ unless stoken
+ debug("Error: processToken: Failed to decode/validate token: #{token}")
+ return
+ end
+ sappid = stoken['appid']
+ unless sappid == appid
+ debug("Error: processToken: Application ID in token did not match ours: #{sappid}, #{appid}")
+ return
+ end
+ begin
+ user = User.new(stoken['ts'], stoken['uid'], stoken['flags'],
+ context, token)
+ return user
+ rescue Exception => e
+ debug("Error: processToken: Contents of token considered invalid: #{e}")
+ return
+ end
+ end
+
+ #####################################################################
+ # Returns an appropriate content type and body response that the
+ # application handler can return to signify a successful sign-out
+ # from the application.
+ #
+ # When a user signs out of Windows Live or a Windows Live
+ # application, a best-effort attempt is made at signing the user out
+ # from all other Windows Live applications the user might be signed
+ # in to. This is done by calling the handler page for each
+ # application with 'action' set to 'clearcookie' in the query
+ # string. The application handler is then responsible for clearing
+ # any cookies or data associated with the sign-in. After successfully
+ # signing the user out, the handler should return a GIF (any GIF)
+ # image as response to the 'action=clearcookie' query.
+ #####################################################################
+ def getClearCookieResponse()
+ type = "image/gif"
+ content = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAIBTAA7"
+ content = Base64.decode64(content)
+ return type, content
+ end
+end
+
+#######################################################################
+# Implementation of the basic methods needed for Delegated
+# Authentication.
+#######################################################################
+class WindowsLiveLogin
+ #####################################################################
+ # Returns the consent URL to use for Delegated Authentication for
+ # the given comma-delimited list of offers.
+ #
+ # If you specify it, 'context' will be returned as-is in the consent
+ # response for site-specific use.
+ #
+ # The registered/configured return URL can also be overridden by
+ # specifying 'ru' here.
+ #
+ # You can change the language in which the consent page is displayed
+ # by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
+ # 'market' parameter.
+ #####################################################################
+ def getConsentUrl(offers, context=nil, ru=nil, market=nil)
+ if (offers.nil? or offers.empty?)
+ fatal("Error: getConsentUrl: Invalid offers list.")
+ end
+ url = consenturl + "Delegation.aspx?ps=#{CGI.escape(offers)}"
+ url += "&appctx=#{CGI.escape(context)}" if context
+ ru = returnurl if (ru.nil? or ru.empty?)
+ url += "&ru=#{CGI.escape(ru)}" if ru
+ pu = policyurl
+ url += "&pl=#{CGI.escape(pu)}" if pu
+ url += "&mkt=#{CGI.escape(market)}" if market
+ url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
+ url
+ end
+
+ #####################################################################
+ # Returns the URL to use to download a new consent token, given the
+ # offers and refresh token.
+ # The registered/configured return URL can also be overridden by
+ # specifying 'ru' here.
+ #####################################################################
+ def getRefreshConsentTokenUrl(offers, refreshtoken, ru)
+ if (offers.nil? or offers.empty?)
+ fatal("Error: getRefreshConsentTokenUrl: Invalid offers list.")
+ end
+ if (refreshtoken.nil? or refreshtoken.empty?)
+ fatal("Error: getRefreshConsentTokenUrl: Invalid refresh token.")
+ end
+ url = consenturl + "RefreshToken.aspx?ps=#{CGI.escape(offers)}"
+ url += "&reft=#{refreshtoken}"
+ ru = returnurl if (ru.nil? or ru.empty?)
+ url += "&ru=#{CGI.escape(ru)}" if ru
+ url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
+ url
+ end
+
+ #####################################################################
+ # Returns the URL for the consent-management user interface.
+ # You can change the language in which the consent page is displayed
+ # by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
+ # 'market' parameter.
+ #####################################################################
+ def getManageConsentUrl(market=nil)
+ url = consenturl + "ManageConsent.aspx"
+ url += "?mkt=#{CGI.escape(market)}" if market
+ url
+ end
+
+ class ConsentToken
+ attr_reader :delegationtoken, :refreshtoken, :sessionkey, :expiry
+ attr_reader :offers, :offers_string, :locationid, :context
+ attr_reader :decodedtoken, :token
+
+ #####################################################################
+ # Indicates whether the delegation token is set and has not expired.
+ #####################################################################
+ def isValid?
+ return false unless delegationtoken
+ return ((Time.now.to_i-300) < expiry.to_i)
+ end
+
+ #####################################################################
+ # Refreshes the current token and replace it. If operation succeeds
+ # true is returned to signify success.
+ #####################################################################
+ def refresh
+ ct = @wll.refreshConsentToken(self)
+ return false unless ct
+ copy(ct)
+ true
+ end
+
+ #####################################################################
+ # Initialize the ConsentToken module with the WindowsLiveLogin,
+ # delegation token, refresh token, session key, expiry, offers,
+ # location ID, context, decoded token, and raw token.
+ #####################################################################
+ def initialize(wll, delegationtoken, refreshtoken, sessionkey, expiry,
+ offers, locationid, context, decodedtoken, token)
+ @wll = wll
+ self.delegationtoken = delegationtoken
+ self.refreshtoken = refreshtoken
+ self.sessionkey = sessionkey
+ self.expiry = expiry
+ self.offers = offers
+ self.locationid = locationid
+ self.context = context
+ self.decodedtoken = decodedtoken
+ self.token = token
+ end
+
+ private
+ attr_writer :delegationtoken, :refreshtoken, :sessionkey, :expiry
+ attr_writer :offers, :offers_string, :locationid, :context
+ attr_writer :decodedtoken, :token, :locationid
+
+ #####################################################################
+ # Sets the delegation token.
+ #####################################################################
+ def delegationtoken=(delegationtoken)
+ if (delegationtoken.nil? or delegationtoken.empty?)
+ raise("Error: ConsentToken: Null delegation token.")
+ end
+ @delegationtoken = delegationtoken
+ end
+
+ #####################################################################
+ # Sets the session key.
+ #####################################################################
+ def sessionkey=(sessionkey)
+ if (sessionkey.nil? or sessionkey.empty?)
+ raise("Error: ConsentToken: Null session key.")
+ end
+ @sessionkey = @wll.u64(sessionkey)
+ end
+
+ #####################################################################
+ # Sets the expiry time of the delegation token.
+ #####################################################################
+ def expiry=(expiry)
+ if (expiry.nil? or expiry.empty?)
+ raise("Error: ConsentToken: Null expiry time.")
+ end
+ expiry = expiry.to_i
+ raise("Error: ConsentToken: Invalid expiry: #{expiry}") if (expiry <= 0)
+ @expiry = Time.at expiry
+ end
+
+ #####################################################################
+ # Sets the offers/actions for which the user granted consent.
+ #####################################################################
+ def offers=(offers)
+ if (offers.nil? or offers.empty?)
+ raise("Error: ConsentToken: Null offers.")
+ end
+
+ @offers_string = ""
+ @offers = []
+
+ offers = CGI.unescape(offers)
+ offers = offers.split(";")
+ offers.each{|offer|
+ offer = offer.split(":")[0]
+ @offers_string += "," unless @offers_string.empty?
+ @offers_string += offer
+ @offers.push(offer)
+ }
+ end
+
+ #####################################################################
+ # Sets the LocationID.
+ #####################################################################
+ def locationid=(locationid)
+ if (locationid.nil? or locationid.empty?)
+ raise("Error: ConsentToken: Null Location ID.")
+ end
+ @locationid = locationid
+ end
+
+ #####################################################################
+ # Makes a copy of the ConsentToken object.
+ #####################################################################
+ def copy(consenttoken)
+ @delegationtoken = consenttoken.delegationtoken
+ @refreshtoken = consenttoken.refreshtoken
+ @sessionkey = consenttoken.sessionkey
+ @expiry = consenttoken.expiry
+ @offers = consenttoken.offers
+ @locationid = consenttoken.locationid
+ @offers_string = consenttoken.offers_string
+ @decodedtoken = consenttoken.decodedtoken
+ @token = consenttoken.token
+ end
+ end
+
+ #####################################################################
+ # Processes the POST response from the Delegated Authentication
+ # service after a user has granted consent. The processConsent
+ # function extracts the consent token string and returns the result
+ # of invoking the processConsentToken method.
+ #####################################################################
+ def processConsent(query)
+ query = parse query
+ unless query
+ debug("Error: processConsent: Failed to parse query.")
+ return
+ end
+ action = query['action']
+ unless action == 'delauth'
+ debug("Warning: processConsent: query action ignored: #{action}.")
+ return
+ end
+ responsecode = query['ResponseCode']
+ unless responsecode == 'RequestApproved'
+ debug("Error: processConsent: Consent was not successfully granted: #{responsecode}")
+ return
+ end
+ token = query['ConsentToken']
+ context = CGI.unescape(query['appctx']) if query['appctx']
+ processConsentToken(token, context)
+ end
+
+ #####################################################################
+ # Processes the consent token string that is returned in the POST
+ # response by the Delegated Authentication service after a
+ # user has granted consent.
+ #####################################################################
+ def processConsentToken(token, context=nil)
+ if token.nil? or token.empty?
+ debug("Error: processConsentToken: Null token.")
+ return
+ end
+ decodedtoken = token
+ parsedtoken = parse(CGI.unescape(decodedtoken))
+ unless parsedtoken
+ debug("Error: processConsentToken: Failed to parse token: #{token}")
+ return
+ end
+ eact = parsedtoken['eact']
+ if eact
+ decodedtoken = decodeAndValidateToken eact
+ unless decodedtoken
+ debug("Error: processConsentToken: Failed to decode/validate token: #{token}")
+ return
+ end
+ parsedtoken = parse(decodedtoken)
+ decodedtoken = CGI.escape(decodedtoken)
+ end
+ begin
+ consenttoken = ConsentToken.new(self,
+ parsedtoken['delt'],
+ parsedtoken['reft'],
+ parsedtoken['skey'],
+ parsedtoken['exp'],
+ parsedtoken['offer'],
+ parsedtoken['lid'],
+ context, decodedtoken, token)
+ return consenttoken
+ rescue Exception => e
+ debug("Error: processConsentToken: Contents of token considered invalid: #{e}")
+ return
+ end
+ end
+
+ #####################################################################
+ # Attempts to obtain a new, refreshed token and return it. The
+ # original token is not modified.
+ #####################################################################
+ def refreshConsentToken(consenttoken, ru=nil)
+ if consenttoken.nil?
+ debug("Error: refreshConsentToken: Null consent token.")
+ return
+ end
+ refreshConsentToken2(consenttoken.offers_string, consenttoken.refreshtoken, ru)
+ end
+
+ #####################################################################
+ # Helper function to obtain a new, refreshed token and return it.
+ # The original token is not modified.
+ #####################################################################
+ def refreshConsentToken2(offers_string, refreshtoken, ru=nil)
+ url = nil
+ begin
+ url = getRefreshConsentTokenUrl(offers_string, refreshtoken, ru)
+ ret = fetch url
+ ret.value # raises exception if fetch failed
+ body = ret.body
+ body.scan(/\{"ConsentToken":"(.*)"\}/){|match|
+ return processConsentToken("#{match}")
+ }
+ debug("Error: refreshConsentToken2: Failed to extract token: #{body}")
+ rescue Exception => e
+ debug("Error: Failed to refresh consent token: #{e}")
+ end
+ return
+ end
+end
+
+#######################################################################
+# Common methods.
+#######################################################################
+class WindowsLiveLogin
+
+ #####################################################################
+ # Decodes and validates the token.
+ #####################################################################
+ def decodeAndValidateToken(token, cryptkey=@cryptkey, signkey=@signkey,
+ internal_allow_recursion=true)
+ haveoldsecret = false
+ if (oldsecretexpiry and (Time.now.to_i < oldsecretexpiry.to_i))
+ haveoldsecret = true if (@oldcryptkey and @oldsignkey)
+ end
+ haveoldsecret = (haveoldsecret and internal_allow_recursion)
+
+ stoken = decodeToken(token, cryptkey)
+ stoken = validateToken(stoken, signkey) if stoken
+ if (stoken.nil? and haveoldsecret)
+ debug("Warning: Failed to validate token with current secret, attempting old secret.")
+ stoken = decodeAndValidateToken(token, @oldcryptkey, @oldsignkey, false)
+ end
+ stoken
+ end
+
+ #####################################################################
+ # Decodes the given token string; returns undef on failure.
+ #
+ # First, the string is URL-unescaped and base64 decoded.
+ # Second, the IV is extracted from the first 16 bytes of the string.
+ # Finally, the string is decrypted using the encryption key.
+ #####################################################################
+ def decodeToken(token, cryptkey=@cryptkey)
+ if (cryptkey.nil? or cryptkey.empty?)
+ fatal("Error: decodeToken: Secret key was not set. Aborting.")
+ end
+ token = u64(token)
+ if (token.nil? or (token.size <= 16) or !(token.size % 16).zero?)
+ debug("Error: decodeToken: Attempted to decode invalid token.")
+ return
+ end
+ iv = token[0..15]
+ crypted = token[16..-1]
+ begin
+ aes128cbc = OpenSSL::Cipher::AES128.new("CBC")
+ aes128cbc.decrypt
+ aes128cbc.iv = iv
+ aes128cbc.key = cryptkey
+ decrypted = aes128cbc.update(crypted) + aes128cbc.final
+ rescue Exception => e
+ debug("Error: decodeToken: Decryption failed: #{token}, #{e}")
+ return
+ end
+ decrypted
+ end
+
+ #####################################################################
+ # Creates a signature for the given string by using the signature
+ # key.
+ #####################################################################
+ def signToken(token, signkey=@signkey)
+ if (signkey.nil? or signkey.empty?)
+ fatal("Error: signToken: Secret key was not set. Aborting.")
+ end
+ begin
+ digest = OpenSSL::Digest::SHA256.new
+ return OpenSSL::HMAC.digest(digest, signkey, token)
+ rescue Exception => e
+ debug("Error: signToken: Signing failed: #{token}, #{e}")
+ return
+ end
+ end
+
+ #####################################################################
+ # Extracts the signature from the token and validates it.
+ #####################################################################
+ def validateToken(token, signkey=@signkey)
+ if (token.nil? or token.empty?)
+ debug("Error: validateToken: Null token.")
+ return
+ end
+ body, sig = token.split("&sig=")
+ if (body.nil? or sig.nil?)
+ debug("Error: validateToken: Invalid token: #{token}")
+ return
+ end
+ sig = u64(sig)
+ return token if (sig == signToken(body, signkey))
+ debug("Error: validateToken: Signature did not match.")
+ return
+ end
+end
+
+#######################################################################
+# Implementation of the methods needed to perform Windows Live
+# application verification as well as trusted sign-in.
+#######################################################################
+class WindowsLiveLogin
+ #####################################################################
+ # Generates an application verifier token. An IP address can
+ # optionally be included in the token.
+ #####################################################################
+ def getAppVerifier(ip=nil)
+ token = "appid=#{appid}&ts=#{timestamp}"
+ token += "&ip=#{ip}" if ip
+ token += "&sig=#{e64(signToken(token))}"
+ CGI.escape token
+ end
+
+ #####################################################################
+ # Returns the URL that is required to retrieve the application
+ # security token.
+ #
+ # By default, the application security token is generated for
+ # the Windows Live site; a specific Site ID can optionally be
+ # specified in 'siteid'. The IP address can also optionally be
+ # included in 'ip'.
+ #
+ # If 'js' is nil, a JavaScript Output Notation (JSON) response is
+ # returned in the following format:
+ #
+ # {"token":""}
+ #
+ # Otherwise, a JavaScript response is returned. It is assumed that
+ # WLIDResultCallback is a custom function implemented to handle the
+ # token value:
+ #
+ # WLIDResultCallback("");
+ #####################################################################
+ def getAppLoginUrl(siteid=nil, ip=nil, js=nil)
+ url = secureurl + "wapplogin.srf?app=#{getAppVerifier(ip)}"
+ url += "&alg=#{securityalgorithm}"
+ url += "&id=#{siteid}" if siteid
+ url += "&js=1" if js
+ url
+ end
+
+ #####################################################################
+ # Retrieves the application security token for application
+ # verification from the application sign-in URL.
+ #
+ # By default, the application security token will be generated for
+ # the Windows Live site; a specific Site ID can optionally be
+ # specified in 'siteid'. The IP address can also optionally be
+ # included in 'ip'.
+ #
+ # Implementation note: The application security token is downloaded
+ # from the application sign-in URL in JSON format:
+ #
+ # {"token":""}
+ #
+ # Therefore we must extract from the string and return it as
+ # seen here.
+ #####################################################################
+ def getAppSecurityToken(siteid=nil, ip=nil)
+ url = getAppLoginUrl(siteid, ip)
+ begin
+ ret = fetch url
+ ret.value # raises exception if fetch failed
+ body = ret.body
+ body.scan(/\{"token":"(.*)"\}/){|match|
+ return match
+ }
+ debug("Error: getAppSecurityToken: Failed to extract token: #{body}")
+ rescue Exception => e
+ debug("Error: getAppSecurityToken: Failed to get token: #{e}")
+ end
+ return
+ end
+
+ #####################################################################
+ # Returns a string that can be passed to the getTrustedParams
+ # function as the 'retcode' parameter. If this is specified as the
+ # 'retcode', the application will be used as return URL after it
+ # finishes trusted sign-in.
+ #####################################################################
+ def getAppRetCode
+ "appid=#{appid}"
+ end
+
+ #####################################################################
+ # Returns a table of key-value pairs that must be posted to the
+ # sign-in URL for trusted sign-in. Use HTTP POST to do this. Be aware
+ # that the values in the table are neither URL nor HTML escaped and
+ # may have to be escaped if you are inserting them in code such as
+ # an HTML form.
+ #
+ # The user to be trusted on the local site is passed in as string
+ # 'user'.
+ #
+ # Optionally, 'retcode' specifies the resource to which successful
+ # sign-in is redirected, such as Windows Live Mail, and is typically
+ # a string in the format 'id=2000'. If you pass in the value from
+ # getAppRetCode instead, sign-in will be redirected to the
+ # application. Otherwise, an HTTP 200 response is returned.
+ #####################################################################
+ def getTrustedParams(user, retcode=nil)
+ token = getTrustedToken(user)
+ return unless token
+ token = %{#{token}uri:WindowsLiveID}
+ params = {}
+ params['wa'] = securityalgorithm
+ params['wresult'] = token
+ params['wctx'] = retcode if retcode
+ params
+ end
+
+ #####################################################################
+ # Returns the trusted sign-in token in the format that is needed by a
+ # control doing trusted sign-in.
+ #
+ # The user to be trusted on the local site is passed in as string
+ # 'user'.
+ #####################################################################
+ def getTrustedToken(user)
+ if user.nil? or user.empty?
+ debug('Error: getTrustedToken: Null user specified.')
+ return
+ end
+ token = "appid=#{appid}&uid=#{CGI.escape(user)}&ts=#{timestamp}"
+ token += "&sig=#{e64(signToken(token))}"
+ CGI.escape token
+ end
+
+ #####################################################################
+ # Returns the trusted sign-in URL to use for the Windows Live Login
+ # server.
+ #####################################################################
+ def getTrustedLoginUrl
+ secureurl + "wlogin.srf"
+ end
+
+ #####################################################################
+ # Returns the trusted sign-out URL to use for the Windows Live Login
+ # server.
+ #####################################################################
+ def getTrustedLogoutUrl
+ secureurl + "logout.srf?appid=#{appid}"
+ end
+end
+
+#######################################################################
+# Helper methods.
+#######################################################################
+class WindowsLiveLogin
+
+ #######################################################################
+ # Function to parse the settings file.
+ #######################################################################
+ def parseSettings(settingsFile)
+ settings = {}
+ begin
+ file = File.new(settingsFile)
+ doc = REXML::Document.new file
+ root = doc.root
+ root.each_element{|e|
+ settings[e.name] = e.text
+ }
+ rescue Exception => e
+ fatal("Error: parseSettings: Error while reading #{settingsFile}: #{e}")
+ end
+ return settings
+ end
+
+ #####################################################################
+ # Derives the key, given the secret key and prefix as described in the
+ # Web Authentication SDK documentation.
+ #####################################################################
+ def derive(secret, prefix)
+ begin
+ fatal("Nil/empty secret.") if (secret.nil? or secret.empty?)
+ key = prefix + secret
+ key = OpenSSL::Digest::SHA256.digest(key)
+ return key[0..15]
+ rescue Exception => e
+ debug("Error: derive: #{e}")
+ return
+ end
+ end
+
+ #####################################################################
+ # Parses query string and return a table
+ # {String=>String}
+ #
+ # If a table is passed in from CGI.params, we convert it from
+ # {String=>[]} to {String=>String}. I believe Rails uses symbols
+ # instead of strings in general, so we convert from symbols to
+ # strings here also.
+ #####################################################################
+ def parse(input)
+ if (input.nil? or input.empty?)
+ debug("Error: parse: Nil/empty input.")
+ return
+ end
+
+ pairs = {}
+ if (input.class == String)
+ input = input.split('&')
+ input.each{|pair|
+ k, v = pair.split('=')
+ pairs[k] = v
+ }
+ else
+ input.each{|k, v|
+ v = v[0] if (v.class == Array)
+ pairs[k.to_s] = v.to_s
+ }
+ end
+ return pairs
+ end
+
+ #####################################################################
+ # Generates a time stamp suitable for the application verifier token.
+ #####################################################################
+ def timestamp
+ Time.now.to_i.to_s
+ end
+
+ #####################################################################
+ # Base64-encodes and URL-escapes a string.
+ #####################################################################
+ def e64(s)
+ return unless s
+ CGI.escape Base64.encode64(s)
+ end
+
+ #####################################################################
+ # URL-unescapes and Base64-decodes a string.
+ #####################################################################
+ def u64(s)
+ return unless s
+ Base64.decode64 CGI.unescape(s)
+ end
+
+ #####################################################################
+ # Fetches the contents given a URL.
+ #####################################################################
+ def fetch(url)
+ url = URI.parse url
+ http = Net::HTTP.new(url.host, url.port)
+ http.use_ssl = (url.scheme == "https")
+ http.request_get url.request_uri
+ end
+end