sundawg_contacts 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ require 'spec_helper'
2
+ require 'contacts/google'
3
+
4
+ describe Contacts::Google do
5
+
6
+ before :each do
7
+ @gmail = create
8
+ end
9
+
10
+ def create
11
+ Contacts::Google.new('dummytoken')
12
+ end
13
+
14
+ after :each do
15
+ FakeWeb.clean_registry
16
+ end
17
+
18
+ describe 'fetches contacts feed via HTTP GET' do
19
+ it 'with defaults' do
20
+ FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/default/thin',
21
+ :body => 'thin results',
22
+ :verify => lambda { |req|
23
+ req['Authorization'].should == %(AuthSub token="dummytoken")
24
+ req['Accept-Encoding'].should == 'gzip'
25
+ req['User-Agent'].should == "Ruby Contacts v#{Contacts::VERSION::STRING} (gzip)"
26
+ }
27
+ )
28
+
29
+ response = @gmail.get({})
30
+ response.body.should == 'thin results'
31
+ end
32
+
33
+ it 'with explicit user ID and full projection' do
34
+ @gmail = Contacts::Google.new('dummytoken', 'person@example.com')
35
+ @gmail.projection = 'full'
36
+
37
+ FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/person%40example.com/full',
38
+ :body => 'full results'
39
+ )
40
+
41
+ response = @gmail.get({})
42
+ response.body.should == 'full results'
43
+ end
44
+ end
45
+
46
+ it 'handles a normal response body' do
47
+ response = mock('HTTP response')
48
+ @gmail.expects(:get).returns(response)
49
+
50
+ response.expects(:'[]').with('Content-Encoding').returns(nil)
51
+ response.expects(:body).returns('<feed/>')
52
+
53
+ @gmail.expects(:parse_contacts).with('<feed/>')
54
+ @gmail.contacts
55
+ end
56
+
57
+ it 'handles gzipped response' do
58
+ response = mock('HTTP response')
59
+ @gmail.expects(:get).returns(response)
60
+
61
+ gzipped = StringIO.new
62
+ gzwriter = Zlib::GzipWriter.new gzipped
63
+ gzwriter.write(('a'..'z').to_a.join)
64
+ gzwriter.close
65
+
66
+ response.expects(:'[]').with('Content-Encoding').returns('gzip')
67
+ response.expects(:body).returns gzipped.string
68
+
69
+ @gmail.expects(:parse_contacts).with('abcdefghijklmnopqrstuvwxyz')
70
+ @gmail.contacts
71
+ end
72
+
73
+ it 'raises a fetching error when something goes awry' do
74
+ FakeWeb::register_uri(:get, 'https://www.google.com/m8/feeds/contacts/default/thin',
75
+ :status => [404, 'YOU FAIL']
76
+ )
77
+
78
+ lambda {
79
+ @gmail.get({})
80
+ }.should raise_error(Net::HTTPServerException)
81
+ end
82
+
83
+ it 'parses the resulting feed into name/email pairs' do
84
+ @gmail.stubs(:get)
85
+ @gmail.expects(:response_body).returns(sample_xml('google-single'))
86
+
87
+ found = @gmail.contacts
88
+ found.size.should == 1
89
+ contact = found.first
90
+ contact.name.should == 'Fitzgerald'
91
+ contact.emails.should == ['fubar@gmail.com']
92
+ end
93
+
94
+ it 'parses a complex feed into name/email pairs' do
95
+ @gmail.stubs(:get)
96
+ @gmail.expects(:response_body).returns(sample_xml('google-many'))
97
+
98
+ found = @gmail.contacts
99
+ found.size.should == 3
100
+ found[0].name.should == 'Elizabeth Bennet'
101
+ found[0].emails.should == ['liz@gmail.com', 'liz@example.org']
102
+ found[1].name.should == 'William Paginate'
103
+ found[1].emails.should == ['will_paginate@googlegroups.com']
104
+ found[2].name.should be_nil
105
+ found[2].emails.should == ['anonymous@example.com']
106
+ end
107
+
108
+ it 'makes modification time available after parsing' do
109
+ @gmail.updated_at.should be_nil
110
+ @gmail.stubs(:get)
111
+ @gmail.expects(:response_body).returns(sample_xml('google-single'))
112
+
113
+ @gmail.contacts
114
+ u = @gmail.updated_at
115
+ u.year.should == 2008
116
+ u.day.should == 5
117
+ @gmail.updated_at_string.should == '2008-03-05T12:36:38.836Z'
118
+ end
119
+
120
+ describe 'GET query parameter handling' do
121
+
122
+ before :each do
123
+ @gmail = create
124
+ @gmail.stubs(:response_body)
125
+ @gmail.stubs(:parse_contacts)
126
+ end
127
+
128
+ it 'abstracts ugly parameters behind nicer ones' do
129
+ expect_params 'max-results' => '25',
130
+ 'orderby' => 'lastmodified',
131
+ 'sortorder' => 'ascending',
132
+ 'start-index' => '11',
133
+ 'updated-min' => 'datetime'
134
+
135
+ @gmail.contacts :limit => 25,
136
+ :offset => 10,
137
+ :order => 'lastmodified',
138
+ :descending => false,
139
+ :updated_after => 'datetime'
140
+ end
141
+
142
+ it 'should have implicit :descending with :order' do
143
+ expect_params 'orderby' => 'lastmodified',
144
+ 'sortorder' => 'descending',
145
+ 'max-results' => '200'
146
+
147
+ @gmail.contacts :order => 'lastmodified'
148
+ end
149
+
150
+ it 'should have default :limit of 200' do
151
+ expect_params 'max-results' => '200'
152
+ @gmail.contacts
153
+ end
154
+
155
+ it 'should skip nil values in parameters' do
156
+ expect_params 'start-index' => '1'
157
+ @gmail.contacts :limit => nil, :offset => 0
158
+ end
159
+
160
+ def expect_params(params)
161
+ query_string = Contacts::Google.query_string(params)
162
+ FakeWeb::register_uri(:get, "https://www.google.com/m8/feeds/contacts/default/thin?#{query_string}", {})
163
+ end
164
+
165
+ end
166
+
167
+ describe 'Retrieving all contacts (in chunks)' do
168
+
169
+ before :each do
170
+ @gmail = create
171
+ end
172
+
173
+ it 'should make only one API call when no more is needed' do
174
+ @gmail.expects(:contacts).with(instance_of(Hash)).once.returns((0..8).to_a)
175
+
176
+ @gmail.all_contacts({}, 10).should == (0..8).to_a
177
+ end
178
+
179
+ it 'should make multiple calls to :contacts when needed' do
180
+ @gmail.expects(:contacts).with(has_entries(:offset => 0 , :limit => 10)).returns(( 0..9 ).to_a)
181
+ @gmail.expects(:contacts).with(has_entries(:offset => 10, :limit => 10)).returns((10..19).to_a)
182
+ @gmail.expects(:contacts).with(has_entries(:offset => 20, :limit => 10)).returns((20..24).to_a)
183
+
184
+ @gmail.all_contacts({}, 10).should == (0..24).to_a
185
+ end
186
+
187
+ it 'should make one extra API call when not sure whether there are more contacts' do
188
+ @gmail.expects(:contacts).with(has_entries(:offset => 0 , :limit => 10)).returns((0..9).to_a)
189
+ @gmail.expects(:contacts).with(has_entries(:offset => 10, :limit => 10)).returns([])
190
+
191
+ @gmail.all_contacts({}, 10).should == (0..9).to_a
192
+ end
193
+
194
+ end
195
+
196
+ end
data/spec/rcov.opts ADDED
@@ -0,0 +1,2 @@
1
+ --exclude ^\/,^spec\/
2
+ --no-validator-links
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --reverse
@@ -0,0 +1,84 @@
1
+ require 'rubygems'
2
+ gem 'rspec', '~> 1.1.3'
3
+ require 'spec'
4
+ gem 'mocha', '~> 0.9.0'
5
+ require 'mocha'
6
+
7
+ require 'cgi'
8
+ require 'fake_web'
9
+ FakeWeb.allow_net_connect = false
10
+
11
+ module SampleFeeds
12
+ FEED_DIR = File.dirname(__FILE__) + '/feeds/'
13
+
14
+ def sample_xml(name)
15
+ File.read "#{FEED_DIR}#{name}.xml"
16
+ end
17
+ end
18
+
19
+ module HttpMocks
20
+ def mock_response(type = :success)
21
+ klass = case type
22
+ when :success then Net::HTTPSuccess
23
+ when :redirect then Net::HTTPRedirection
24
+ when :fail then Net::HTTPClientError
25
+ else type
26
+ end
27
+
28
+ klass.new(nil, nil, nil)
29
+ end
30
+
31
+ def mock_connection(ssl = true)
32
+ connection = mock('HTTP connection')
33
+ connection.stubs(:start)
34
+ connection.stubs(:finish)
35
+ if ssl
36
+ connection.expects(:use_ssl=).with(true)
37
+ connection.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
38
+ end
39
+ connection
40
+ end
41
+ end
42
+
43
+ Spec::Runner.configure do |config|
44
+ config.include SampleFeeds, HttpMocks
45
+ # config.predicate_matchers[:swim] = :can_swim?
46
+
47
+ config.mock_with :mocha
48
+ end
49
+
50
+ module Mocha
51
+ module ParameterMatchers
52
+ def query_string(entries, partial = false)
53
+ QueryStringMatcher.new(entries, partial)
54
+ end
55
+ end
56
+ end
57
+
58
+ class QueryStringMatcher < Mocha::ParameterMatchers::Base
59
+
60
+ def initialize(entries, partial)
61
+ @entries = entries
62
+ @partial = partial
63
+ end
64
+
65
+ def matches?(available_parameters)
66
+ string = available_parameters.shift.split('?').last
67
+ broken = string.split('&').map { |pair| pair.split('=').map { |value| CGI.unescape(value) } }
68
+ hash = Hash[*broken.flatten]
69
+
70
+ if @partial
71
+ has_entry_matchers = @entries.map do |key, value|
72
+ Mocha::ParameterMatchers::HasEntry.new(key, value)
73
+ end
74
+ Mocha::ParameterMatchers::AllOf.new(*has_entry_matchers).matches?([hash])
75
+ else
76
+ @entries == hash
77
+ end
78
+ end
79
+
80
+ def mocha_inspect
81
+ "query_string(#{@entries.mocha_inspect})"
82
+ end
83
+
84
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+ require 'contacts/windows_live'
3
+
4
+ describe Contacts::WindowsLive do
5
+
6
+ before(:each) do
7
+ @path = Dir.getwd + '/spec/feeds/'
8
+ @wl = Contacts::WindowsLive.new(@path + 'contacts.yml')
9
+ end
10
+
11
+ it 'parse the XML contacts document' do
12
+ contacts = Contacts::WindowsLive.parse_xml(contacts_xml)
13
+
14
+ contacts[0].name.should be_nil
15
+ contacts[0].email.should == 'froz@gmail.com'
16
+ contacts[1].name.should == 'Rafael Timbo'
17
+ contacts[1].email.should == 'timbo@hotmail.com'
18
+ contacts[2].name.should be_nil
19
+ contacts[2].email.should == 'betinho@hotmail.com'
20
+
21
+ end
22
+
23
+ it 'should can be initialized by a YAML file' do
24
+ wll = @wl.instance_variable_get('@wll')
25
+
26
+ wll.appid.should == 'your_app_id'
27
+ wll.securityalgorithm.should == 'wsignin1.0'
28
+ wll.returnurl.should == 'http://yourserver.com/your_return_url'
29
+ end
30
+
31
+ def contacts_xml
32
+ File.open(@path + 'wl_contacts.xml', 'r+').read
33
+ end
34
+ end
@@ -0,0 +1,83 @@
1
+ require 'spec_helper'
2
+ require 'contacts/yahoo'
3
+
4
+ describe Contacts::Yahoo do
5
+
6
+ before(:each) do
7
+ @path = Dir.getwd + '/spec/feeds/'
8
+ @yahoo = Contacts::Yahoo.new(@path + 'contacts.yml')
9
+ end
10
+
11
+ it 'should generate an athentication URL' do
12
+ auth_url = @yahoo.get_authentication_url()
13
+ auth_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wslogin\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&sig=.*/)
14
+ end
15
+
16
+ it 'should have a simple interface to grab the contacts' do
17
+ @yahoo.expects(:access_user_credentials).returns(read_file('yh_credential.xml'))
18
+ @yahoo.expects(:access_address_book_api).returns(read_file('yh_contacts.txt'))
19
+
20
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
21
+
22
+ results = @yahoo.contacts(redirect_path)
23
+ results.should have_contact('Hugo Barauna', 'hugo.barauna@gmail.com')
24
+ results.should have_contact('Nina Benchimol', 'nina@hotmail.com')
25
+ results.should have_contact('Andrea Dimitri', 'and@yahoo.com')
26
+ results.should have_contact('Ricardo Fiorelli', 'ricardo@poli.usp.br')
27
+ results.should have_contact('Priscila', 'pizinha@yahoo.com.br')
28
+ end
29
+
30
+ it 'should validate yahoo redirect signature' do
31
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
32
+
33
+ @yahoo.validate_signature(redirect_path).should be_true
34
+ @yahoo.token.should == 'AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-'
35
+ end
36
+
37
+ it 'should detect when the redirect is not valid' do
38
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=de4fe4ebd50a8075f75dcc23f6aca04f'
39
+
40
+ lambda{ @yahoo.validate_signature(redirect_path) }.should raise_error
41
+ end
42
+
43
+ it 'should generate the credential request URL' do
44
+ redirect_path = '/?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&token=AB.KoEg8vBwvJKFkwfcDTJEMKhGeAD6KhiDe0aZLCvoJzMeQG00-&appdata=&ts=1218501215&sig=d381fba89c7e9d3c14788720733c3fbf'
45
+ @yahoo.validate_signature(redirect_path)
46
+
47
+ @yahoo.get_credential_url.should match(/https:\/\/api.login.yahoo.com\/WSLogin\/V1\/wspwtoken_login\?appid=i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--&ts=.*&token=.*&sig=.*/)
48
+ end
49
+
50
+ it 'should parse the credential XML' do
51
+ @yahoo.parse_credentials(read_file('yh_credential.xml'))
52
+
53
+ @yahoo.wssid.should == 'tr.jZsW/ulc'
54
+ @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'
55
+ end
56
+
57
+ it 'should parse the contacts json response' do
58
+ json = read_file('yh_contacts.txt')
59
+
60
+ Contacts::Yahoo.parse_contacts(json).should have_contact('Hugo Barauna', 'hugo.barauna@gmail.com')
61
+ Contacts::Yahoo.parse_contacts(json).should have_contact('Nina Benchimol', 'nina@hotmail.com')
62
+ Contacts::Yahoo.parse_contacts(json).should have_contact('Andrea Dimitri', 'and@yahoo.com')
63
+ Contacts::Yahoo.parse_contacts(json).should have_contact('Ricardo Fiorelli', 'ricardo@poli.usp.br')
64
+ Contacts::Yahoo.parse_contacts(json).should have_contact('Priscila', 'pizinha@yahoo.com.br')
65
+ end
66
+
67
+ it 'should can be initialized by a YAML file' do
68
+ @yahoo.appid.should == 'i%3DB%26p%3DUw70JGIdHWVRbpqYItcMw--'
69
+ @yahoo.secret.should == 'a34f389cbd135de4618eed5e23409d34450'
70
+ end
71
+
72
+ def read_file(file)
73
+ File.open(@path + file, 'r+').read
74
+ end
75
+
76
+ def have_contact(name, email)
77
+ matcher_class = Class.new()
78
+ matcher_class.instance_eval do
79
+ define_method(:matches?) {|some_contacts| some_contacts.any? {|a_contact| a_contact.name == name && a_contact.emails.include?(email)}}
80
+ end
81
+ matcher_class.new
82
+ end
83
+ end
@@ -0,0 +1,33 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{sundawg_contacts}
5
+ s.version = "0.0.1"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Christopher Sun"]
9
+ s.date = %q{2010-04-13}
10
+ s.description = %q{Project fork of Mislav Contacts to support signed GData protocol.}
11
+ s.email = %q{christopher.sun@gmail.com}
12
+ s.extra_rdoc_files = ["LICENSE", "README.rdoc", "lib/config/contacts.yml", "lib/contacts.rb", "lib/contacts/flickr.rb", "lib/contacts/google.rb", "lib/contacts/version.rb", "lib/contacts/windows_live.rb", "lib/contacts/yahoo.rb"]
13
+ s.files = ["LICENSE", "MIT-LICENSE", "Manifest", "README.rdoc", "Rakefile", "lib/config/contacts.yml", "lib/contacts.rb", "lib/contacts/flickr.rb", "lib/contacts/google.rb", "lib/contacts/version.rb", "lib/contacts/windows_live.rb", "lib/contacts/yahoo.rb", "spec/contact_spec.rb", "spec/feeds/contacts.yml", "spec/feeds/flickr/auth.getFrob.xml", "spec/feeds/flickr/auth.getToken.xml", "spec/feeds/google-many.xml", "spec/feeds/google-single.xml", "spec/feeds/wl_contacts.xml", "spec/feeds/yh_contacts.txt", "spec/feeds/yh_credential.xml", "spec/flickr/auth_spec.rb", "spec/gmail/auth_spec.rb", "spec/gmail/fetching_spec.rb", "spec/rcov.opts", "spec/spec.opts", "spec/spec_helper.rb", "spec/windows_live/windows_live_spec.rb", "spec/yahoo/yahoo_spec.rb", "vendor/windows_live_login.rb", "sundawg_contacts.gemspec"]
14
+ s.homepage = %q{http://github.com/SunDawg/contacts}
15
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Sundawg_contacts", "--main", "README.rdoc"]
16
+ s.require_paths = ["lib"]
17
+ s.rubyforge_project = %q{sundawg_contacts}
18
+ s.rubygems_version = %q{1.3.6}
19
+ s.summary = %q{Project fork of Mislav Contacts to support signed GData protocol.}
20
+
21
+ if s.respond_to? :specification_version then
22
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
23
+ s.specification_version = 3
24
+
25
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
26
+ s.add_development_dependency(%q<fakeweb>, [">= 1.2.7"])
27
+ else
28
+ s.add_dependency(%q<fakeweb>, [">= 1.2.7"])
29
+ end
30
+ else
31
+ s.add_dependency(%q<fakeweb>, [">= 1.2.7"])
32
+ end
33
+ end
@@ -0,0 +1,1156 @@
1
+ #######################################################################
2
+ # This SDK is provide by Microsoft. I just use it for the delegated
3
+ # authentication part. You can find it in http://www.microsoft.com/downloads/details.aspx?FamilyId=24195B4E-6335-4844-A71D-7D395D20E67B&displaylang=en
4
+ #
5
+ # Author: Hugo Baraúna (hugo.barauna@gmail.com)
6
+ #######################################################################
7
+
8
+
9
+ #######################################################################
10
+ #######################################################################
11
+ # FILE: windowslivelogin.rb
12
+ #
13
+ # DESCRIPTION: Sample implementation of Web Authentication and
14
+ # Delegated Authentication protocol in Ruby. Also
15
+ # includes trusted sign-in and application verification
16
+ # sample implementations.
17
+ #
18
+ # VERSION: 1.1
19
+ #
20
+ # Copyright (c) 2008 Microsoft Corporation. All Rights Reserved.
21
+ #######################################################################
22
+
23
+ require 'cgi'
24
+ require 'uri'
25
+ require 'base64'
26
+ require 'digest/sha2'
27
+ require 'hmac-sha2'
28
+ require 'net/https'
29
+ require 'rexml/document'
30
+
31
+ class WindowsLiveLogin
32
+ # Raised when user denies permission and windows live returns with a
33
+ # post body having action=cancel (instead of action=delauth)
34
+ class PermissionDenied < StandardError; end
35
+
36
+ #####################################################################
37
+ # Stub implementation for logging errors. If you want to enable
38
+ # debugging output using the default mechanism, specify true.
39
+ # By default, debug information will be printed to the standard
40
+ # error output and should be visible in the web server logs.
41
+ #####################################################################
42
+ def setDebug(flag)
43
+ @debug = flag
44
+ end
45
+
46
+ #####################################################################
47
+ # Stub implementation for logging errors. By default, this function
48
+ # does nothing if the debug flag has not been set with setDebug.
49
+ # Otherwise, it tries to log the error message.
50
+ #####################################################################
51
+ def debug(error)
52
+ return unless @debug
53
+ return if error.nil? or error.empty?
54
+ warn("Windows Live ID Authentication SDK #{error}")
55
+ nil
56
+ end
57
+
58
+ #####################################################################
59
+ # Stub implementation for handling a fatal error.
60
+ #####################################################################
61
+ def fatal(error)
62
+ debug(error)
63
+ raise(error)
64
+ end
65
+
66
+ #####################################################################
67
+ # Initialize the WindowsLiveLogin module with the application ID,
68
+ # secret key, and security algorithm.
69
+ #
70
+ # We recommend that you employ strong measures to protect the
71
+ # secret key. The secret key should never be exposed to the Web
72
+ # or other users.
73
+ #
74
+ # Be aware that if you do not supply these settings at
75
+ # initialization time, you may need to set the corresponding
76
+ # properties manually.
77
+ #
78
+ # For Delegated Authentication, you may optionally specify the
79
+ # privacy policy URL and return URL. If you do not specify these
80
+ # values here, the default values that you specified when you
81
+ # registered your application will be used.
82
+ #
83
+ # The 'force_delauth_nonprovisioned' flag also indicates whether
84
+ # your application is registered for Delegated Authentication
85
+ # (that is, whether it uses an application ID and secret key). We
86
+ # recommend that your Delegated Authentication application always
87
+ # be registered for enhanced security and functionality.
88
+ #####################################################################
89
+ def initialize(appid=nil, secret=nil, securityalgorithm=nil,
90
+ force_delauth_nonprovisioned=nil,
91
+ policyurl=nil, returnurl=nil)
92
+ self.force_delauth_nonprovisioned = force_delauth_nonprovisioned
93
+ self.appid = appid if appid
94
+ self.secret = secret if secret
95
+ self.securityalgorithm = securityalgorithm if securityalgorithm
96
+ self.policyurl = policyurl if policyurl
97
+ self.returnurl = returnurl if returnurl
98
+ end
99
+
100
+ #####################################################################
101
+ # Initialize the WindowsLiveLogin module from a settings file.
102
+ #
103
+ # 'settingsFile' specifies the location of the XML settings file
104
+ # that contains the application ID, secret key, and security
105
+ # algorithm. The file is of the following format:
106
+ #
107
+ # <windowslivelogin>
108
+ # <appid>APPID</appid>
109
+ # <secret>SECRET</secret>
110
+ # <securityalgorithm>wsignin1.0</securityalgorithm>
111
+ # </windowslivelogin>
112
+ #
113
+ # In a Delegated Authentication scenario, you may also specify
114
+ # 'returnurl' and 'policyurl' in the settings file, as shown in the
115
+ # Delegated Authentication samples.
116
+ #
117
+ # We recommend that you store the WindowsLiveLogin settings file
118
+ # in an area on your server that cannot be accessed through the
119
+ # Internet. This file contains important confidential information.
120
+ #####################################################################
121
+ def self.initFromXml(settingsFile)
122
+ o = self.new
123
+ settings = o.parseSettings(settingsFile)
124
+
125
+ o.setDebug(settings['debug'] == 'true')
126
+ o.force_delauth_nonprovisioned =
127
+ (settings['force_delauth_nonprovisioned'] == 'true')
128
+
129
+ o.appid = settings['appid']
130
+ o.secret = settings['secret']
131
+ o.oldsecret = settings['oldsecret']
132
+ o.oldsecretexpiry = settings['oldsecretexpiry']
133
+ o.securityalgorithm = settings['securityalgorithm']
134
+ o.policyurl = settings['policyurl']
135
+ o.returnurl = settings['returnurl']
136
+ o.baseurl = settings['baseurl']
137
+ o.secureurl = settings['secureurl']
138
+ o.consenturl = settings['consenturl']
139
+ o
140
+ end
141
+
142
+ #####################################################################
143
+ # Sets the application ID. Use this method if you did not specify
144
+ # an application ID at initialization.
145
+ #####################################################################
146
+ def appid=(appid)
147
+ if (appid.nil? or appid.empty?)
148
+ return if force_delauth_nonprovisioned
149
+ fatal("Error: appid: Null application ID.")
150
+ end
151
+ if (not appid =~ /^\w+$/)
152
+ fatal("Error: appid: Application ID must be alpha-numeric: " + appid)
153
+ end
154
+ @appid = appid
155
+ end
156
+
157
+ #####################################################################
158
+ # Returns the application ID.
159
+ #####################################################################
160
+ def appid
161
+ if (@appid.nil? or @appid.empty?)
162
+ fatal("Error: appid: App ID was not set. Aborting.")
163
+ end
164
+ @appid
165
+ end
166
+
167
+ #####################################################################
168
+ # Sets your secret key. Use this method if you did not specify
169
+ # a secret key at initialization.
170
+ #####################################################################
171
+ def secret=(secret)
172
+ if (secret.nil? or secret.empty?)
173
+ return if force_delauth_nonprovisioned
174
+ fatal("Error: secret=: Secret must be non-null.")
175
+ end
176
+ if (secret.size < 16)
177
+ fatal("Error: secret=: Secret must be at least 16 characters.")
178
+ end
179
+ @signkey = derive(secret, "SIGNATURE")
180
+ @cryptkey = derive(secret, "ENCRYPTION")
181
+ end
182
+
183
+ #####################################################################
184
+ # Sets your old secret key.
185
+ #
186
+ # Use this property to set your old secret key if you are in the
187
+ # process of transitioning to a new secret key. You may need this
188
+ # property because the Windows Live ID servers can take up to
189
+ # 24 hours to propagate a new secret key after you have updated
190
+ # your application settings.
191
+ #
192
+ # If an old secret key is specified here and has not expired
193
+ # (as determined by the oldsecretexpiry setting), it will be used
194
+ # as a fallback if token decryption fails with the new secret
195
+ # key.
196
+ #####################################################################
197
+ def oldsecret=(secret)
198
+ return if (secret.nil? or secret.empty?)
199
+ if (secret.size < 16)
200
+ fatal("Error: oldsecret=: Secret must be at least 16 characters.")
201
+ end
202
+ @oldsignkey = derive(secret, "SIGNATURE")
203
+ @oldcryptkey = derive(secret, "ENCRYPTION")
204
+ end
205
+
206
+ #####################################################################
207
+ # Sets the expiry time for your old secret key.
208
+ #
209
+ # After this time has passed, the old secret key will no longer be
210
+ # used even if token decryption fails with the new secret key.
211
+ #
212
+ # The old secret expiry time is represented as the number of seconds
213
+ # elapsed since January 1, 1970.
214
+ #####################################################################
215
+ def oldsecretexpiry=(timestamp)
216
+ return if (timestamp.nil? or timestamp.empty?)
217
+ timestamp = timestamp.to_i
218
+ fatal("Error: oldsecretexpiry=: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
219
+ @oldsecretexpiry = Time.at timestamp
220
+ end
221
+
222
+ #####################################################################
223
+ # Gets the old secret key expiry time.
224
+ #####################################################################
225
+ attr_accessor :oldsecretexpiry
226
+
227
+ #####################################################################
228
+ # Sets or gets the version of the security algorithm being used.
229
+ #####################################################################
230
+ attr_accessor :securityalgorithm
231
+
232
+ def securityalgorithm
233
+ if(@securityalgorithm.nil? or @securityalgorithm.empty?)
234
+ "wsignin1.0"
235
+ else
236
+ @securityalgorithm
237
+ end
238
+ end
239
+
240
+ #####################################################################
241
+ # Sets a flag that indicates whether Delegated Authentication
242
+ # is non-provisioned (i.e. does not use an application ID or secret
243
+ # key).
244
+ #####################################################################
245
+ attr_accessor :force_delauth_nonprovisioned
246
+
247
+ #####################################################################
248
+ # Sets the privacy policy URL, to which the Windows Live ID consent
249
+ # service redirects users to view the privacy policy of your Web
250
+ # site for Delegated Authentication.
251
+ #####################################################################
252
+ def policyurl=(policyurl)
253
+ if ((policyurl.nil? or policyurl.empty?) and force_delauth_nonprovisioned)
254
+ fatal("Error: policyurl=: Invalid policy URL specified.")
255
+ end
256
+ @policyurl = policyurl
257
+ end
258
+
259
+ #####################################################################
260
+ # Gets the privacy policy URL for your site.
261
+ #####################################################################
262
+ def policyurl
263
+ if (@policyurl.nil? or @policyurl.empty?)
264
+ 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.")
265
+ raise("Error: policyurl: Policy URL must be set in a Del Auth non-provisioned scenario. Aborting.") if force_delauth_nonprovisioned
266
+ end
267
+ @policyurl
268
+ end
269
+
270
+ #####################################################################
271
+ # Sets the return URL--the URL on your site to which the consent
272
+ # service redirects users (along with the action, consent token,
273
+ # and application context) after they have successfully provided
274
+ # consent information for Delegated Authentication. This value will
275
+ # override the return URL specified during registration.
276
+ #####################################################################
277
+ def returnurl=(returnurl)
278
+ if ((returnurl.nil? or returnurl.empty?) and force_delauth_nonprovisioned)
279
+ fatal("Error: returnurl=: Invalid return URL specified.")
280
+ end
281
+ @returnurl = returnurl
282
+ end
283
+
284
+
285
+ #####################################################################
286
+ # Returns the return URL of your site.
287
+ #####################################################################
288
+ def returnurl
289
+ if ((@returnurl.nil? or @returnurl.empty?) and force_delauth_nonprovisioned)
290
+ fatal("Error: returnurl: Return URL must be set in a Del Auth non-provisioned scenario. Aborting.")
291
+ end
292
+ @returnurl
293
+ end
294
+
295
+ #####################################################################
296
+ # Sets or gets the base URL to use for the Windows Live Login server. You
297
+ # should not have to change this property. Furthermore, we recommend
298
+ # that you use the Sign In control instead of the URL methods
299
+ # provided here.
300
+ #####################################################################
301
+ attr_accessor :baseurl
302
+
303
+ def baseurl
304
+ if(@baseurl.nil? or @baseurl.empty?)
305
+ "http://login.live.com/"
306
+ else
307
+ @baseurl
308
+ end
309
+ end
310
+
311
+ #####################################################################
312
+ # Sets or gets the secure (HTTPS) URL to use for the Windows Live Login
313
+ # server. You should not have to change this property.
314
+ #####################################################################
315
+ attr_accessor :secureurl
316
+
317
+ def secureurl
318
+ if(@secureurl.nil? or @secureurl.empty?)
319
+ "https://login.live.com/"
320
+ else
321
+ @secureurl
322
+ end
323
+ end
324
+
325
+ #####################################################################
326
+ # Sets or gets the Consent Base URL to use for the Windows Live Consent
327
+ # server. You should not have to use or change this property directly.
328
+ #####################################################################
329
+ attr_accessor :consenturl
330
+
331
+ def consenturl
332
+ if(@consenturl.nil? or @consenturl.empty?)
333
+ "https://consent.live.com/"
334
+ else
335
+ @consenturl
336
+ end
337
+ end
338
+ end
339
+
340
+ #######################################################################
341
+ # Implementation of the basic methods needed for Web Authentication.
342
+ #######################################################################
343
+ class WindowsLiveLogin
344
+ #####################################################################
345
+ # Returns the sign-in URL to use for the Windows Live Login server.
346
+ # We recommend that you use the Sign In control instead.
347
+ #
348
+ # If you specify it, 'context' will be returned as-is in the sign-in
349
+ # response for site-specific use.
350
+ #####################################################################
351
+ def getLoginUrl(context=nil, market=nil)
352
+ url = baseurl + "wlogin.srf?appid=#{appid}"
353
+ url += "&alg=#{securityalgorithm}"
354
+ url += "&appctx=#{CGI.escape(context)}" if context
355
+ url += "&mkt=#{CGI.escape(market)}" if market
356
+ url
357
+ end
358
+
359
+ #####################################################################
360
+ # Returns the sign-out URL to use for the Windows Live Login server.
361
+ # We recommend that you use the Sign In control instead.
362
+ #####################################################################
363
+ def getLogoutUrl(market=nil)
364
+ url = baseurl + "logout.srf?appid=#{appid}"
365
+ url += "&mkt=#{CGI.escape(market)}" if market
366
+ url
367
+ end
368
+
369
+ #####################################################################
370
+ # Holds the user information after a successful sign-in.
371
+ #
372
+ # 'timestamp' is the time as obtained from the SSO token.
373
+ # 'id' is the pairwise unique ID for the user.
374
+ # 'context' is the application context that was originally passed to
375
+ # the sign-in request, if any.
376
+ # 'token' is the encrypted Web Authentication token that contains the
377
+ # UID. This can be cached in a cookie and the UID can be retrieved by
378
+ # calling the processToken method.
379
+ # 'usePersistentCookie?' indicates whether the application is
380
+ # expected to store the user token in a session or persistent
381
+ # cookie.
382
+ #####################################################################
383
+ class User
384
+ attr_reader :timestamp, :id, :context, :token
385
+
386
+ def usePersistentCookie?
387
+ @usePersistentCookie
388
+ end
389
+
390
+
391
+ #####################################################################
392
+ # Initialize the User with time stamp, userid, flags, context and token.
393
+ #####################################################################
394
+ def initialize(timestamp, id, flags, context, token)
395
+ self.timestamp = timestamp
396
+ self.id = id
397
+ self.flags = flags
398
+ self.context = context
399
+ self.token = token
400
+ end
401
+
402
+ private
403
+ attr_writer :timestamp, :id, :flags, :context, :token
404
+
405
+ #####################################################################
406
+ # Sets or gets the Unix timestamp as obtained from the SSO token.
407
+ #####################################################################
408
+ def timestamp=(timestamp)
409
+ raise("Error: User: Null timestamp in token.") unless timestamp
410
+ timestamp = timestamp.to_i
411
+ raise("Error: User: Invalid timestamp: #{timestamp}") if (timestamp <= 0)
412
+ @timestamp = Time.at timestamp
413
+ end
414
+
415
+ #####################################################################
416
+ # Sets or gets the pairwise unique ID for the user.
417
+ #####################################################################
418
+ def id=(id)
419
+ raise("Error: User: Null id in token.") unless id
420
+ raise("Error: User: Invalid id: #{id}") unless (id =~ /^\w+$/)
421
+ @id = id
422
+ end
423
+
424
+ #####################################################################
425
+ # Sets or gets the usePersistentCookie flag for the user.
426
+ #####################################################################
427
+ def flags=(flags)
428
+ @usePersistentCookie = false
429
+ if flags
430
+ @usePersistentCookie = ((flags.to_i % 2) == 1)
431
+ end
432
+ end
433
+ end
434
+
435
+ #####################################################################
436
+ # Processes the sign-in response from the Windows Live sign-in server.
437
+ #
438
+ # 'query' contains the preprocessed POST table, such as that
439
+ # returned by CGI.params or Rails. (The unprocessed POST string
440
+ # could also be used here but we do not recommend it).
441
+ #
442
+ # This method returns a User object on successful sign-in; otherwise
443
+ # it returns nil.
444
+ #####################################################################
445
+ def processLogin(query)
446
+ query = parse query
447
+ unless query
448
+ debug("Error: processLogin: Failed to parse query.")
449
+ return
450
+ end
451
+ action = query['action']
452
+ unless action == 'login'
453
+ debug("Warning: processLogin: query action ignored: #{action}.")
454
+ return
455
+ end
456
+ token = query['stoken']
457
+ context = CGI.unescape(query['appctx']) if query['appctx']
458
+ processToken(token, context)
459
+ end
460
+
461
+ #####################################################################
462
+ # Decodes and validates a Web Authentication token. Returns a User
463
+ # object on success. If a context is passed in, it will be returned
464
+ # as the context field in the User object.
465
+ #####################################################################
466
+ def processToken(token, context=nil)
467
+ if token.nil? or token.empty?
468
+ debug("Error: processToken: Null/empty token.")
469
+ return
470
+ end
471
+ stoken = decodeAndValidateToken token
472
+ stoken = parse stoken
473
+ unless stoken
474
+ debug("Error: processToken: Failed to decode/validate token: #{token}")
475
+ return
476
+ end
477
+ sappid = stoken['appid']
478
+ unless sappid == appid
479
+ debug("Error: processToken: Application ID in token did not match ours: #{sappid}, #{appid}")
480
+ return
481
+ end
482
+ begin
483
+ user = User.new(stoken['ts'], stoken['uid'], stoken['flags'],
484
+ context, token)
485
+ return user
486
+ rescue Exception => e
487
+ debug("Error: processToken: Contents of token considered invalid: #{e}")
488
+ return
489
+ end
490
+ end
491
+
492
+ #####################################################################
493
+ # Returns an appropriate content type and body response that the
494
+ # application handler can return to signify a successful sign-out
495
+ # from the application.
496
+ #
497
+ # When a user signs out of Windows Live or a Windows Live
498
+ # application, a best-effort attempt is made at signing the user out
499
+ # from all other Windows Live applications the user might be signed
500
+ # in to. This is done by calling the handler page for each
501
+ # application with 'action' set to 'clearcookie' in the query
502
+ # string. The application handler is then responsible for clearing
503
+ # any cookies or data associated with the sign-in. After successfully
504
+ # signing the user out, the handler should return a GIF (any GIF)
505
+ # image as response to the 'action=clearcookie' query.
506
+ #####################################################################
507
+ def getClearCookieResponse()
508
+ type = "image/gif"
509
+ content = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAEALAAAAAABAAEAAAIBTAA7"
510
+ content = Base64.decode64(content)
511
+ return type, content
512
+ end
513
+ end
514
+
515
+ #######################################################################
516
+ # Implementation of the basic methods needed for Delegated
517
+ # Authentication.
518
+ #######################################################################
519
+ class WindowsLiveLogin
520
+ #####################################################################
521
+ # Returns the consent URL to use for Delegated Authentication for
522
+ # the given comma-delimited list of offers.
523
+ #
524
+ # If you specify it, 'context' will be returned as-is in the consent
525
+ # response for site-specific use.
526
+ #
527
+ # The registered/configured return URL can also be overridden by
528
+ # specifying 'ru' here.
529
+ #
530
+ # You can change the language in which the consent page is displayed
531
+ # by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
532
+ # 'market' parameter.
533
+ #####################################################################
534
+ def getConsentUrl(offers, context=nil, ru=nil, market=nil)
535
+ if (offers.nil? or offers.empty?)
536
+ fatal("Error: getConsentUrl: Invalid offers list.")
537
+ end
538
+ url = consenturl + "Delegation.aspx?ps=#{CGI.escape(offers)}"
539
+ url += "&appctx=#{CGI.escape(context)}" if context
540
+ ru = returnurl if (ru.nil? or ru.empty?)
541
+ url += "&ru=#{CGI.escape(ru)}" if ru
542
+ pu = policyurl
543
+ url += "&pl=#{CGI.escape(pu)}" if pu
544
+ url += "&mkt=#{CGI.escape(market)}" if market
545
+ url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
546
+ url
547
+ end
548
+
549
+ #####################################################################
550
+ # Returns the URL to use to download a new consent token, given the
551
+ # offers and refresh token.
552
+ # The registered/configured return URL can also be overridden by
553
+ # specifying 'ru' here.
554
+ #####################################################################
555
+ def getRefreshConsentTokenUrl(offers, refreshtoken, ru)
556
+ if (offers.nil? or offers.empty?)
557
+ fatal("Error: getRefreshConsentTokenUrl: Invalid offers list.")
558
+ end
559
+ if (refreshtoken.nil? or refreshtoken.empty?)
560
+ fatal("Error: getRefreshConsentTokenUrl: Invalid refresh token.")
561
+ end
562
+ url = consenturl + "RefreshToken.aspx?ps=#{CGI.escape(offers)}"
563
+ url += "&reft=#{refreshtoken}"
564
+ ru = returnurl if (ru.nil? or ru.empty?)
565
+ url += "&ru=#{CGI.escape(ru)}" if ru
566
+ url += "&app=#{getAppVerifier()}" unless force_delauth_nonprovisioned
567
+ url
568
+ end
569
+
570
+ #####################################################################
571
+ # Returns the URL for the consent-management user interface.
572
+ # You can change the language in which the consent page is displayed
573
+ # by specifying a culture ID (For example, 'fr-fr' or 'en-us') in the
574
+ # 'market' parameter.
575
+ #####################################################################
576
+ def getManageConsentUrl(market=nil)
577
+ url = consenturl + "ManageConsent.aspx"
578
+ url += "?mkt=#{CGI.escape(market)}" if market
579
+ url
580
+ end
581
+
582
+ class ConsentToken
583
+ attr_reader :delegationtoken, :refreshtoken, :sessionkey, :expiry
584
+ attr_reader :offers, :offers_string, :locationid, :context
585
+ attr_reader :decodedtoken, :token
586
+
587
+ #####################################################################
588
+ # Indicates whether the delegation token is set and has not expired.
589
+ #####################################################################
590
+ def isValid?
591
+ return false unless delegationtoken
592
+ return ((Time.now.to_i-300) < expiry.to_i)
593
+ end
594
+
595
+ #####################################################################
596
+ # Refreshes the current token and replace it. If operation succeeds
597
+ # true is returned to signify success.
598
+ #####################################################################
599
+ def refresh
600
+ ct = @wll.refreshConsentToken(self)
601
+ return false unless ct
602
+ copy(ct)
603
+ true
604
+ end
605
+
606
+ #####################################################################
607
+ # Initialize the ConsentToken module with the WindowsLiveLogin,
608
+ # delegation token, refresh token, session key, expiry, offers,
609
+ # location ID, context, decoded token, and raw token.
610
+ #####################################################################
611
+ def initialize(wll, delegationtoken, refreshtoken, sessionkey, expiry,
612
+ offers, locationid, context, decodedtoken, token)
613
+ @wll = wll
614
+ self.delegationtoken = delegationtoken
615
+ self.refreshtoken = refreshtoken
616
+ self.sessionkey = sessionkey
617
+ self.expiry = expiry
618
+ self.offers = offers
619
+ self.locationid = locationid
620
+ self.context = context
621
+ self.decodedtoken = decodedtoken
622
+ self.token = token
623
+ end
624
+
625
+ private
626
+ attr_writer :delegationtoken, :refreshtoken, :sessionkey, :expiry
627
+ attr_writer :offers, :offers_string, :locationid, :context
628
+ attr_writer :decodedtoken, :token, :locationid
629
+
630
+ #####################################################################
631
+ # Sets the delegation token.
632
+ #####################################################################
633
+ def delegationtoken=(delegationtoken)
634
+ if (delegationtoken.nil? or delegationtoken.empty?)
635
+ raise("Error: ConsentToken: Null delegation token.")
636
+ end
637
+ @delegationtoken = delegationtoken
638
+ end
639
+
640
+ #####################################################################
641
+ # Sets the session key.
642
+ #####################################################################
643
+ def sessionkey=(sessionkey)
644
+ if (sessionkey.nil? or sessionkey.empty?)
645
+ raise("Error: ConsentToken: Null session key.")
646
+ end
647
+ @sessionkey = @wll.u64(sessionkey)
648
+ end
649
+
650
+ #####################################################################
651
+ # Sets the expiry time of the delegation token.
652
+ #####################################################################
653
+ def expiry=(expiry)
654
+ if (expiry.nil? or expiry.empty?)
655
+ raise("Error: ConsentToken: Null expiry time.")
656
+ end
657
+ expiry = expiry.to_i
658
+ raise("Error: ConsentToken: Invalid expiry: #{expiry}") if (expiry <= 0)
659
+ @expiry = Time.at expiry
660
+ end
661
+
662
+ #####################################################################
663
+ # Sets the offers/actions for which the user granted consent.
664
+ #####################################################################
665
+ def offers=(offers)
666
+ if (offers.nil? or offers.empty?)
667
+ raise("Error: ConsentToken: Null offers.")
668
+ end
669
+
670
+ @offers_string = ""
671
+ @offers = []
672
+
673
+ offers = CGI.unescape(offers)
674
+ offers = offers.split(";")
675
+ offers.each{|offer|
676
+ offer = offer.split(":")[0]
677
+ @offers_string += "," unless @offers_string.empty?
678
+ @offers_string += offer
679
+ @offers.push(offer)
680
+ }
681
+ end
682
+
683
+ #####################################################################
684
+ # Sets the LocationID.
685
+ #####################################################################
686
+ def locationid=(locationid)
687
+ if (locationid.nil? or locationid.empty?)
688
+ raise("Error: ConsentToken: Null Location ID.")
689
+ end
690
+ @locationid = locationid
691
+ end
692
+
693
+ #####################################################################
694
+ # Makes a copy of the ConsentToken object.
695
+ #####################################################################
696
+ def copy(consenttoken)
697
+ @delegationtoken = consenttoken.delegationtoken
698
+ @refreshtoken = consenttoken.refreshtoken
699
+ @sessionkey = consenttoken.sessionkey
700
+ @expiry = consenttoken.expiry
701
+ @offers = consenttoken.offers
702
+ @locationid = consenttoken.locationid
703
+ @offers_string = consenttoken.offers_string
704
+ @decodedtoken = consenttoken.decodedtoken
705
+ @token = consenttoken.token
706
+ end
707
+ end
708
+
709
+ #####################################################################
710
+ # Processes the POST response from the Delegated Authentication
711
+ # service after a user has granted consent. The processConsent
712
+ # function extracts the consent token string and returns the result
713
+ # of invoking the processConsentToken method.
714
+ #####################################################################
715
+ def processConsent(query)
716
+ query = parse query
717
+ unless query
718
+ debug("Error: processConsent: Failed to parse query.")
719
+ return
720
+ end
721
+ action = query['action']
722
+ if action == 'cancel'
723
+ raise WindowsLiveLogin::PermissionDenied, 'Permission denied by end user'
724
+ elsif action != 'delauth'
725
+ debug("Warning: processConsent: query action ignored: #{action}.")
726
+ return
727
+ end
728
+ responsecode = query['ResponseCode']
729
+ unless responsecode == 'RequestApproved'
730
+ debug("Error: processConsent: Consent was not successfully granted: #{responsecode}")
731
+ return
732
+ end
733
+ token = query['ConsentToken']
734
+ context = CGI.unescape(query['appctx']) if query['appctx']
735
+ processConsentToken(token, context)
736
+ end
737
+
738
+ #####################################################################
739
+ # Processes the consent token string that is returned in the POST
740
+ # response by the Delegated Authentication service after a
741
+ # user has granted consent.
742
+ #####################################################################
743
+ def processConsentToken(token, context=nil)
744
+ if token.nil? or token.empty?
745
+ debug("Error: processConsentToken: Null token.")
746
+ return
747
+ end
748
+ decodedtoken = token
749
+ parsedtoken = parse(CGI.unescape(decodedtoken))
750
+ unless parsedtoken
751
+ debug("Error: processConsentToken: Failed to parse token: #{token}")
752
+ return
753
+ end
754
+ eact = parsedtoken['eact']
755
+ if eact
756
+ decodedtoken = decodeAndValidateToken eact
757
+ unless decodedtoken
758
+ debug("Error: processConsentToken: Failed to decode/validate token: #{token}")
759
+ return
760
+ end
761
+ parsedtoken = parse(decodedtoken)
762
+ decodedtoken = CGI.escape(decodedtoken)
763
+ end
764
+ begin
765
+ consenttoken = ConsentToken.new(self,
766
+ parsedtoken['delt'],
767
+ parsedtoken['reft'],
768
+ parsedtoken['skey'],
769
+ parsedtoken['exp'],
770
+ parsedtoken['offer'],
771
+ parsedtoken['lid'],
772
+ context, decodedtoken, token)
773
+ return consenttoken
774
+ rescue Exception => e
775
+ debug("Error: processConsentToken: Contents of token considered invalid: #{e}")
776
+ return
777
+ end
778
+ end
779
+
780
+ #####################################################################
781
+ # Attempts to obtain a new, refreshed token and return it. The
782
+ # original token is not modified.
783
+ #####################################################################
784
+ def refreshConsentToken(consenttoken, ru=nil)
785
+ if consenttoken.nil?
786
+ debug("Error: refreshConsentToken: Null consent token.")
787
+ return
788
+ end
789
+ refreshConsentToken2(consenttoken.offers_string, consenttoken.refreshtoken, ru)
790
+ end
791
+
792
+ #####################################################################
793
+ # Helper function to obtain a new, refreshed token and return it.
794
+ # The original token is not modified.
795
+ #####################################################################
796
+ def refreshConsentToken2(offers_string, refreshtoken, ru=nil)
797
+ url = nil
798
+ begin
799
+ url = getRefreshConsentTokenUrl(offers_string, refreshtoken, ru)
800
+ ret = fetch url
801
+ ret.value # raises exception if fetch failed
802
+ body = ret.body
803
+ body.scan(/\{"ConsentToken":"(.*)"\}/){|match|
804
+ return processConsentToken("#{match}")
805
+ }
806
+ debug("Error: refreshConsentToken2: Failed to extract token: #{body}")
807
+ rescue Exception => e
808
+ debug("Error: Failed to refresh consent token: #{e}")
809
+ end
810
+ return
811
+ end
812
+ end
813
+
814
+ #######################################################################
815
+ # Common methods.
816
+ #######################################################################
817
+ class WindowsLiveLogin
818
+
819
+ #####################################################################
820
+ # Decodes and validates the token.
821
+ #####################################################################
822
+ def decodeAndValidateToken(token, cryptkey=@cryptkey, signkey=@signkey,
823
+ internal_allow_recursion=true)
824
+ haveoldsecret = false
825
+ if (oldsecretexpiry and (Time.now.to_i < oldsecretexpiry.to_i))
826
+ haveoldsecret = true if (@oldcryptkey and @oldsignkey)
827
+ end
828
+ haveoldsecret = (haveoldsecret and internal_allow_recursion)
829
+
830
+ stoken = decodeToken(token, cryptkey)
831
+ stoken = validateToken(stoken, signkey) if stoken
832
+ if (stoken.nil? and haveoldsecret)
833
+ debug("Warning: Failed to validate token with current secret, attempting old secret.")
834
+ stoken = decodeAndValidateToken(token, @oldcryptkey, @oldsignkey, false)
835
+ end
836
+ stoken
837
+ end
838
+
839
+ #####################################################################
840
+ # Decodes the given token string; returns undef on failure.
841
+ #
842
+ # First, the string is URL-unescaped and base64 decoded.
843
+ # Second, the IV is extracted from the first 16 bytes of the string.
844
+ # Finally, the string is decrypted using the encryption key.
845
+ #####################################################################
846
+ def decodeToken(token, cryptkey=@cryptkey)
847
+ if (cryptkey.nil? or cryptkey.empty?)
848
+ fatal("Error: decodeToken: Secret key was not set. Aborting.")
849
+ end
850
+ token = u64(token)
851
+ if (token.nil? or (token.size <= 16) or !(token.size % 16).zero?)
852
+ debug("Error: decodeToken: Attempted to decode invalid token.")
853
+ return
854
+ end
855
+ iv = token[0..15]
856
+ crypted = token[16..-1]
857
+ begin
858
+ aes128cbc = OpenSSL::Cipher::AES128.new("CBC")
859
+ aes128cbc.decrypt
860
+ aes128cbc.iv = iv
861
+ aes128cbc.key = cryptkey
862
+ decrypted = aes128cbc.update(crypted) + aes128cbc.final
863
+ rescue Exception => e
864
+ debug("Error: decodeToken: Decryption failed: #{token}, #{e}")
865
+ return
866
+ end
867
+ decrypted
868
+ end
869
+
870
+ #####################################################################
871
+ # Creates a signature for the given string by using the signature
872
+ # key.
873
+ #####################################################################
874
+ def signToken(token, signkey=@signkey)
875
+ if (signkey.nil? or signkey.empty?)
876
+ fatal("Error: signToken: Secret key was not set. Aborting.")
877
+ end
878
+ begin
879
+ return HMAC::SHA256.digest(signkey, token)
880
+ rescue Exception => e
881
+ debug("Error: signToken: Signing failed: #{token}, #{e}")
882
+ return
883
+ end
884
+ end
885
+
886
+ #####################################################################
887
+ # Extracts the signature from the token and validates it.
888
+ #####################################################################
889
+ def validateToken(token, signkey=@signkey)
890
+ if (token.nil? or token.empty?)
891
+ debug("Error: validateToken: Null token.")
892
+ return
893
+ end
894
+ body, sig = token.split("&sig=")
895
+ if (body.nil? or sig.nil?)
896
+ debug("Error: validateToken: Invalid token: #{token}")
897
+ return
898
+ end
899
+ sig = u64(sig)
900
+ return token if (sig == signToken(body, signkey))
901
+ debug("Error: validateToken: Signature did not match.")
902
+ return
903
+ end
904
+ end
905
+
906
+ #######################################################################
907
+ # Implementation of the methods needed to perform Windows Live
908
+ # application verification as well as trusted sign-in.
909
+ #######################################################################
910
+ class WindowsLiveLogin
911
+ #####################################################################
912
+ # Generates an application verifier token. An IP address can
913
+ # optionally be included in the token.
914
+ #####################################################################
915
+ def getAppVerifier(ip=nil)
916
+ token = "appid=#{appid}&ts=#{timestamp}"
917
+ token += "&ip=#{ip}" if ip
918
+ token += "&sig=#{e64(signToken(token))}"
919
+ CGI.escape token
920
+ end
921
+
922
+ #####################################################################
923
+ # Returns the URL that is required to retrieve the application
924
+ # security token.
925
+ #
926
+ # By default, the application security token is generated for
927
+ # the Windows Live site; a specific Site ID can optionally be
928
+ # specified in 'siteid'. The IP address can also optionally be
929
+ # included in 'ip'.
930
+ #
931
+ # If 'js' is nil, a JavaScript Output Notation (JSON) response is
932
+ # returned in the following format:
933
+ #
934
+ # {"token":"<value>"}
935
+ #
936
+ # Otherwise, a JavaScript response is returned. It is assumed that
937
+ # WLIDResultCallback is a custom function implemented to handle the
938
+ # token value:
939
+ #
940
+ # WLIDResultCallback("<tokenvalue>");
941
+ #####################################################################
942
+ def getAppLoginUrl(siteid=nil, ip=nil, js=nil)
943
+ url = secureurl + "wapplogin.srf?app=#{getAppVerifier(ip)}"
944
+ url += "&alg=#{securityalgorithm}"
945
+ url += "&id=#{siteid}" if siteid
946
+ url += "&js=1" if js
947
+ url
948
+ end
949
+
950
+ #####################################################################
951
+ # Retrieves the application security token for application
952
+ # verification from the application sign-in URL.
953
+ #
954
+ # By default, the application security token will be generated for
955
+ # the Windows Live site; a specific Site ID can optionally be
956
+ # specified in 'siteid'. The IP address can also optionally be
957
+ # included in 'ip'.
958
+ #
959
+ # Implementation note: The application security token is downloaded
960
+ # from the application sign-in URL in JSON format:
961
+ #
962
+ # {"token":"<value>"}
963
+ #
964
+ # Therefore we must extract <value> from the string and return it as
965
+ # seen here.
966
+ #####################################################################
967
+ def getAppSecurityToken(siteid=nil, ip=nil)
968
+ url = getAppLoginUrl(siteid, ip)
969
+ begin
970
+ ret = fetch url
971
+ ret.value # raises exception if fetch failed
972
+ body = ret.body
973
+ body.scan(/\{"token":"(.*)"\}/){|match|
974
+ return match
975
+ }
976
+ debug("Error: getAppSecurityToken: Failed to extract token: #{body}")
977
+ rescue Exception => e
978
+ debug("Error: getAppSecurityToken: Failed to get token: #{e}")
979
+ end
980
+ return
981
+ end
982
+
983
+ #####################################################################
984
+ # Returns a string that can be passed to the getTrustedParams
985
+ # function as the 'retcode' parameter. If this is specified as the
986
+ # 'retcode', the application will be used as return URL after it
987
+ # finishes trusted sign-in.
988
+ #####################################################################
989
+ def getAppRetCode
990
+ "appid=#{appid}"
991
+ end
992
+
993
+ #####################################################################
994
+ # Returns a table of key-value pairs that must be posted to the
995
+ # sign-in URL for trusted sign-in. Use HTTP POST to do this. Be aware
996
+ # that the values in the table are neither URL nor HTML escaped and
997
+ # may have to be escaped if you are inserting them in code such as
998
+ # an HTML form.
999
+ #
1000
+ # The user to be trusted on the local site is passed in as string
1001
+ # 'user'.
1002
+ #
1003
+ # Optionally, 'retcode' specifies the resource to which successful
1004
+ # sign-in is redirected, such as Windows Live Mail, and is typically
1005
+ # a string in the format 'id=2000'. If you pass in the value from
1006
+ # getAppRetCode instead, sign-in will be redirected to the
1007
+ # application. Otherwise, an HTTP 200 response is returned.
1008
+ #####################################################################
1009
+ def getTrustedParams(user, retcode=nil)
1010
+ token = getTrustedToken(user)
1011
+ return unless token
1012
+ token = %{<wst:RequestSecurityTokenResponse xmlns:wst="http://schemas.xmlsoap.org/ws/2005/02/trust"><wst:RequestedSecurityToken><wsse:BinarySecurityToken xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">#{token}</wsse:BinarySecurityToken></wst:RequestedSecurityToken><wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"><wsa:EndpointReference xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"><wsa:Address>uri:WindowsLiveID</wsa:Address></wsa:EndpointReference></wsp:AppliesTo></wst:RequestSecurityTokenResponse>}
1013
+ params = {}
1014
+ params['wa'] = securityalgorithm
1015
+ params['wresult'] = token
1016
+ params['wctx'] = retcode if retcode
1017
+ params
1018
+ end
1019
+
1020
+ #####################################################################
1021
+ # Returns the trusted sign-in token in the format that is needed by a
1022
+ # control doing trusted sign-in.
1023
+ #
1024
+ # The user to be trusted on the local site is passed in as string
1025
+ # 'user'.
1026
+ #####################################################################
1027
+ def getTrustedToken(user)
1028
+ if user.nil? or user.empty?
1029
+ debug('Error: getTrustedToken: Null user specified.')
1030
+ return
1031
+ end
1032
+ token = "appid=#{appid}&uid=#{CGI.escape(user)}&ts=#{timestamp}"
1033
+ token += "&sig=#{e64(signToken(token))}"
1034
+ CGI.escape token
1035
+ end
1036
+
1037
+ #####################################################################
1038
+ # Returns the trusted sign-in URL to use for the Windows Live Login
1039
+ # server.
1040
+ #####################################################################
1041
+ def getTrustedLoginUrl
1042
+ secureurl + "wlogin.srf"
1043
+ end
1044
+
1045
+ #####################################################################
1046
+ # Returns the trusted sign-out URL to use for the Windows Live Login
1047
+ # server.
1048
+ #####################################################################
1049
+ def getTrustedLogoutUrl
1050
+ secureurl + "logout.srf?appid=#{appid}"
1051
+ end
1052
+ end
1053
+
1054
+ #######################################################################
1055
+ # Helper methods.
1056
+ #######################################################################
1057
+ class WindowsLiveLogin
1058
+
1059
+ #######################################################################
1060
+ # Function to parse the settings file.
1061
+ #######################################################################
1062
+ def parseSettings(settingsFile)
1063
+ settings = {}
1064
+ begin
1065
+ file = File.new(settingsFile)
1066
+ doc = REXML::Document.new file
1067
+ root = doc.root
1068
+ root.each_element{|e|
1069
+ settings[e.name] = e.text
1070
+ }
1071
+ rescue Exception => e
1072
+ fatal("Error: parseSettings: Error while reading #{settingsFile}: #{e}")
1073
+ end
1074
+ return settings
1075
+ end
1076
+
1077
+ #####################################################################
1078
+ # Derives the key, given the secret key and prefix as described in the
1079
+ # Web Authentication SDK documentation.
1080
+ #####################################################################
1081
+ def derive(secret, prefix)
1082
+ begin
1083
+ fatal("Nil/empty secret.") if (secret.nil? or secret.empty?)
1084
+ key = prefix + secret
1085
+ key = Digest::SHA256.digest(key)
1086
+ return key[0..15]
1087
+ rescue Exception => e
1088
+ debug("Error: derive: #{e}")
1089
+ return
1090
+ end
1091
+ end
1092
+
1093
+ #####################################################################
1094
+ # Parses query string and return a table
1095
+ # {String=>String}
1096
+ #
1097
+ # If a table is passed in from CGI.params, we convert it from
1098
+ # {String=>[]} to {String=>String}. I believe Rails uses symbols
1099
+ # instead of strings in general, so we convert from symbols to
1100
+ # strings here also.
1101
+ #####################################################################
1102
+ def parse(input)
1103
+ if (input.nil? or input.empty?)
1104
+ debug("Error: parse: Nil/empty input.")
1105
+ return
1106
+ end
1107
+
1108
+ pairs = {}
1109
+ if (input.class == String)
1110
+ input = input.split('&')
1111
+ input.each{|pair|
1112
+ k, v = pair.split('=')
1113
+ pairs[k] = v
1114
+ }
1115
+ else
1116
+ input.each{|k, v|
1117
+ v = v[0] if (v.class == Array)
1118
+ pairs[k.to_s] = v.to_s
1119
+ }
1120
+ end
1121
+ return pairs
1122
+ end
1123
+
1124
+ #####################################################################
1125
+ # Generates a time stamp suitable for the application verifier token.
1126
+ #####################################################################
1127
+ def timestamp
1128
+ Time.now.to_i.to_s
1129
+ end
1130
+
1131
+ #####################################################################
1132
+ # Base64-encodes and URL-escapes a string.
1133
+ #####################################################################
1134
+ def e64(s)
1135
+ return unless s
1136
+ CGI.escape Base64.encode64(s)
1137
+ end
1138
+
1139
+ #####################################################################
1140
+ # URL-unescapes and Base64-decodes a string.
1141
+ #####################################################################
1142
+ def u64(s)
1143
+ return unless s
1144
+ Base64.decode64 CGI.unescape(s)
1145
+ end
1146
+
1147
+ #####################################################################
1148
+ # Fetches the contents given a URL.
1149
+ #####################################################################
1150
+ def fetch(url)
1151
+ url = URI.parse url
1152
+ http = Net::HTTP.new(url.host, url.port)
1153
+ http.use_ssl = (url.scheme == "https")
1154
+ http.request_get url.request_uri
1155
+ end
1156
+ end