sundawg_contacts 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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