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