lperichon-contacts 1.0
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.
- data/.gitmodules +3 -0
- data/LICENSE +18 -0
- data/README.markdown +111 -0
- data/Rakefile +55 -0
- data/lib/contacts/consumer.rb +123 -0
- data/lib/contacts/google.rb +70 -0
- data/lib/contacts/oauth_consumer.rb +70 -0
- data/lib/contacts/util.rb +24 -0
- data/lib/contacts/version.rb +9 -0
- data/lib/contacts/windows_live.rb +188 -0
- data/lib/contacts/yahoo.rb +76 -0
- data/lib/contacts.rb +85 -0
- data/rails/init.rb +2 -0
- data/spec/contact_spec.rb +41 -0
- data/spec/feeds/contacts.yml +10 -0
- data/spec/feeds/flickr/auth.getFrob.xml +4 -0
- data/spec/feeds/flickr/auth.getToken.xml +5 -0
- data/spec/feeds/google-many.xml +48 -0
- data/spec/feeds/google-single.xml +46 -0
- data/spec/feeds/wl_contacts.xml +29 -0
- data/spec/feeds/yh_contacts.txt +119 -0
- data/spec/feeds/yh_credential.xml +28 -0
- data/spec/flickr/auth_spec.rb +80 -0
- data/spec/gmail/auth_spec.rb +70 -0
- data/spec/gmail/fetching_spec.rb +196 -0
- data/spec/rcov.opts +2 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +84 -0
- data/spec/windows_live/windows_live_spec.rb +34 -0
- data/spec/yahoo/yahoo_spec.rb +83 -0
- metadata +90 -0
data/.gitmodules
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Copyright (c) 2009 Mislav Marohnić
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
5
|
+
the Software without restriction, including without limitation the rights to
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
7
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
8
|
+
subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
15
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
16
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
## Contacts
|
2
|
+
|
3
|
+
Fetch users' contact lists without asking them to provide their
|
4
|
+
passwords, as painlessly as possible.
|
5
|
+
|
6
|
+
Contacts provides adapters for:
|
7
|
+
|
8
|
+
* Google
|
9
|
+
* Yahoo!
|
10
|
+
* Windows Live
|
11
|
+
|
12
|
+
## Basic usage instructions
|
13
|
+
|
14
|
+
First, register your application with the service providers you
|
15
|
+
require. Instructions below under "Setting up your accounts".
|
16
|
+
|
17
|
+
Now, create a consumer:
|
18
|
+
|
19
|
+
consumer = Contacts::Google.new
|
20
|
+
consumer = Contacts::Yahoo.new
|
21
|
+
consumer = Contacts::WindowsLive.new
|
22
|
+
# OR by parameter:
|
23
|
+
# provider is one of :google, :yahoo, :windows_live
|
24
|
+
consumer = Contacts.new_consumer(provider)
|
25
|
+
|
26
|
+
Now, direct your user to:
|
27
|
+
|
28
|
+
consumer.authentication_url(return_url)
|
29
|
+
|
30
|
+
`return_url` is the page the user will be redirected to by the service
|
31
|
+
once authorization has been granted. You should also persist the
|
32
|
+
consumer object so you can grab the contacts once the user returns:
|
33
|
+
|
34
|
+
session[:consumer] = consumer.serialize
|
35
|
+
|
36
|
+
Now in the request handler of the return_url above:
|
37
|
+
|
38
|
+
consumer = Contacts.deserialize(session[:consumer])
|
39
|
+
if consumer.authorize(params)
|
40
|
+
@contacts = consumer.contacts
|
41
|
+
else
|
42
|
+
# handle error
|
43
|
+
end
|
44
|
+
|
45
|
+
Here, `params` is the hash of request parameters that the user returns
|
46
|
+
with. `consumer.authorize` returns true if the authorization was
|
47
|
+
successful, false otherwise.
|
48
|
+
|
49
|
+
The list of `@contacts` are `Contacts::Contact` objects which have
|
50
|
+
these attributes:
|
51
|
+
|
52
|
+
* name
|
53
|
+
* emails
|
54
|
+
|
55
|
+
## Setting up your accounts
|
56
|
+
|
57
|
+
### Google
|
58
|
+
|
59
|
+
Set up your projects
|
60
|
+
[here](http://code.google.com/apis/accounts/docs/RegistrationForWebAppsAuto.html).
|
61
|
+
|
62
|
+
* When redirecting to an unverified domain (e.g., localhost), the
|
63
|
+
user sees a warning when authorizing access.
|
64
|
+
|
65
|
+
What this means:
|
66
|
+
|
67
|
+
* You can use the same consumer key for development as production.
|
68
|
+
|
69
|
+
### Yahoo
|
70
|
+
|
71
|
+
Set up your projects [here](https://developer.apps.yahoo.com/projects)
|
72
|
+
|
73
|
+
* When a project's domain is verified, you must redirect to it.
|
74
|
+
* When a project's domain is not verified, you can redirect anywhere, and the
|
75
|
+
user sees a warning when authorizing access.
|
76
|
+
|
77
|
+
What this means:
|
78
|
+
|
79
|
+
* Set up separate production and development projects.
|
80
|
+
* Verify the domain of your production project. You will only be able to
|
81
|
+
redirect to your production domain when using this key.
|
82
|
+
* Don't verify the domain of your development project. You will be able to
|
83
|
+
redirect to any domain when using this key.
|
84
|
+
|
85
|
+
## Windows Live
|
86
|
+
|
87
|
+
Set up your projects [here](http://msdn.microsoft.com/en-us/library/cc287659.aspx).
|
88
|
+
|
89
|
+
* There is no domain verification step.
|
90
|
+
* The domain cannot be localhost, an IP address, or have a query string.
|
91
|
+
* You must redirect to the project's domain, on any port, and the URL may not
|
92
|
+
have a query string or fragment.
|
93
|
+
* You must specify a privacy policy URL.
|
94
|
+
|
95
|
+
What this means:
|
96
|
+
|
97
|
+
* Set up separate production and development projects.
|
98
|
+
* For development, use a domain like myapp.local.
|
99
|
+
* Map this domain to 127.0.0.1 in /etc/hosts or a local DNS server.
|
100
|
+
* If you want to run your app on a different domain (e.g., localhost:3000),
|
101
|
+
redirect the POST from Windows Live to a GET on the original domain. This
|
102
|
+
ensures the popup window has the same origin as the opener page, in
|
103
|
+
accordance with browser same origin policies.
|
104
|
+
|
105
|
+
## Copyright
|
106
|
+
|
107
|
+
Copyright (c) 2010 [George Ogata](mailto:george.ogata@gmail.com) See
|
108
|
+
LICENSE for details.
|
109
|
+
|
110
|
+
Derived from [Mislav's Contacts](http://github.com/mislav/contacts),
|
111
|
+
Copyright (c) 2009 [Mislav Marohnić](mailto:mislav.marohnic@gmail.com)
|
data/Rakefile
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec/rake/spectask'
|
2
|
+
require 'rake/rdoctask'
|
3
|
+
|
4
|
+
task :default => :spec
|
5
|
+
|
6
|
+
spec_opts = 'spec/spec.opts'
|
7
|
+
spec_glob = FileList['spec/**/*_spec.rb']
|
8
|
+
libs = ['lib', 'spec', 'vendor/fakeweb/lib']
|
9
|
+
|
10
|
+
desc 'Run all specs in spec directory'
|
11
|
+
Spec::Rake::SpecTask.new(:spec) do |t|
|
12
|
+
t.libs = libs
|
13
|
+
t.spec_opts = ['--options', spec_opts]
|
14
|
+
t.spec_files = spec_glob
|
15
|
+
# t.warning = true
|
16
|
+
end
|
17
|
+
|
18
|
+
namespace :spec do
|
19
|
+
desc 'Analyze spec coverage with RCov'
|
20
|
+
Spec::Rake::SpecTask.new(:rcov) do |t|
|
21
|
+
t.libs = libs
|
22
|
+
t.spec_files = spec_glob
|
23
|
+
t.spec_opts = ['--options', spec_opts]
|
24
|
+
t.rcov = true
|
25
|
+
t.rcov_opts = lambda do
|
26
|
+
IO.readlines('spec/rcov.opts').map { |l| l.chomp.split(" ") }.flatten
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
desc 'Print Specdoc for all specs'
|
31
|
+
Spec::Rake::SpecTask.new(:doc) do |t|
|
32
|
+
t.libs = libs
|
33
|
+
t.spec_opts = ['--format', 'specdoc', '--dry-run']
|
34
|
+
t.spec_files = spec_glob
|
35
|
+
end
|
36
|
+
|
37
|
+
desc 'Generate HTML report'
|
38
|
+
Spec::Rake::SpecTask.new(:html) do |t|
|
39
|
+
t.libs = libs
|
40
|
+
t.spec_opts = ['--format', 'html:doc/spec.html', '--diff']
|
41
|
+
t.spec_files = spec_glob
|
42
|
+
t.fail_on_error = false
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
desc 'Generate RDoc documentation'
|
47
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
48
|
+
rdoc.rdoc_files.add ['README.rdoc', 'MIT-LICENSE', 'lib/**/*.rb']
|
49
|
+
rdoc.main = 'README.rdoc'
|
50
|
+
rdoc.title = 'Ruby Contacts library'
|
51
|
+
|
52
|
+
rdoc.rdoc_dir = 'doc'
|
53
|
+
rdoc.options << '--inline-source'
|
54
|
+
rdoc.options << '--charset=UTF-8'
|
55
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module Contacts
|
2
|
+
class Consumer
|
3
|
+
#
|
4
|
+
# Configure this consumer from the given hash.
|
5
|
+
#
|
6
|
+
def self.configure(configuration)
|
7
|
+
@configuration = Util.symbolize_keys(configuration)
|
8
|
+
end
|
9
|
+
|
10
|
+
#
|
11
|
+
# The configuration for this consumer.
|
12
|
+
#
|
13
|
+
def self.configuration
|
14
|
+
@configuration
|
15
|
+
end
|
16
|
+
|
17
|
+
#
|
18
|
+
# Define an instance-level reader for the named configuration
|
19
|
+
# attribute.
|
20
|
+
#
|
21
|
+
# Example:
|
22
|
+
#
|
23
|
+
# class MyConsumer < Consumer
|
24
|
+
# configuration_attribute :app_id
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# MyConsumer.configure(:app_id => 'foo')
|
28
|
+
# consumer = MyConsumer.new
|
29
|
+
# consumer.app_id # "foo"
|
30
|
+
#
|
31
|
+
def self.configuration_attribute(name)
|
32
|
+
class_eval <<-EOS
|
33
|
+
def #{name}
|
34
|
+
self.class.configuration[:#{name}]
|
35
|
+
end
|
36
|
+
EOS
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize(options={})
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# Return a string of serialized data.
|
44
|
+
#
|
45
|
+
# You may reconstruct the consumer by passing this string to
|
46
|
+
# .deserialize.
|
47
|
+
#
|
48
|
+
def serialize
|
49
|
+
params_to_query(serializable_data)
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Create a consumer from the given +string+ of serialized data.
|
54
|
+
#
|
55
|
+
# The serialized data should have been returned by #serialize.
|
56
|
+
#
|
57
|
+
def self.deserialize(string)
|
58
|
+
data = string ? query_to_params(string) : {}
|
59
|
+
consumer = new
|
60
|
+
consumer.initialize_serialized(data) if data
|
61
|
+
consumer
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# Authorize the consumer's token from the given
|
66
|
+
# parameters. +params+ is the request parameters the user is
|
67
|
+
# redirected to your site with.
|
68
|
+
#
|
69
|
+
# Return true if authorization is successful, false otherwise. If
|
70
|
+
# unsuccessful, an error message is set in #error. Authorization
|
71
|
+
# may fail, for example, if the user denied access, or the
|
72
|
+
# authorization is forged.
|
73
|
+
#
|
74
|
+
def authorize(params)
|
75
|
+
raise NotImplementedError, 'abstract'
|
76
|
+
end
|
77
|
+
|
78
|
+
#
|
79
|
+
# An error message for the last call to #authorize.
|
80
|
+
#
|
81
|
+
attr_accessor :error
|
82
|
+
|
83
|
+
#
|
84
|
+
# Return the list of contacts, or nil if none could be retrieved.
|
85
|
+
#
|
86
|
+
def contacts
|
87
|
+
raise NotImplementedError, 'abstract'
|
88
|
+
end
|
89
|
+
|
90
|
+
protected
|
91
|
+
|
92
|
+
def initialize_serialized(data)
|
93
|
+
raise NotImplementedError, 'abstract'
|
94
|
+
end
|
95
|
+
|
96
|
+
def serialized_data
|
97
|
+
raise NotImplementedError, 'abstract'
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.params_to_query(params)
|
101
|
+
params.map do |key, value|
|
102
|
+
"#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
|
103
|
+
end.join('&')
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.query_to_params(data)
|
107
|
+
params={}
|
108
|
+
data.split(/&/).each do |pair|
|
109
|
+
key, value = *pair.split(/=/)
|
110
|
+
params[CGI.unescape(key)] = value ? CGI.unescape(value) : ''
|
111
|
+
end
|
112
|
+
params
|
113
|
+
end
|
114
|
+
|
115
|
+
def params_to_query(params)
|
116
|
+
self.class.params_to_query(params)
|
117
|
+
end
|
118
|
+
|
119
|
+
def query_to_params(data)
|
120
|
+
self.class.query_to_params(data)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
|
3
|
+
module Contacts
|
4
|
+
class Google < OAuthConsumer
|
5
|
+
CONSUMER_OPTIONS = Util.frozen_hash(
|
6
|
+
:site => "https://www.google.com",
|
7
|
+
:request_token_path => "/accounts/OAuthGetRequestToken",
|
8
|
+
:access_token_path => "/accounts/OAuthGetAccessToken",
|
9
|
+
:authorize_path => "/accounts/OAuthAuthorizeToken"
|
10
|
+
)
|
11
|
+
|
12
|
+
REQUEST_TOKEN_PARAMS = Util.frozen_hash(
|
13
|
+
'scope' => "https://www.google.com/m8/feeds/"
|
14
|
+
)
|
15
|
+
|
16
|
+
def initialize(options={})
|
17
|
+
super(CONSUMER_OPTIONS, REQUEST_TOKEN_PARAMS)
|
18
|
+
end
|
19
|
+
|
20
|
+
def contacts(options={})
|
21
|
+
return nil if @access_token.nil?
|
22
|
+
params = {:limit => 200}.update(options)
|
23
|
+
google_params = translate_parameters(params)
|
24
|
+
query = params_to_query(google_params)
|
25
|
+
response = @access_token.get("/m8/feeds/contacts/default/thin?#{query}")
|
26
|
+
parse_contacts(response.body)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def translate_parameters(params)
|
32
|
+
params.inject({}) do |all, pair|
|
33
|
+
key, value = pair
|
34
|
+
unless value.nil?
|
35
|
+
key = case key
|
36
|
+
when :limit
|
37
|
+
'max-results'
|
38
|
+
when :offset
|
39
|
+
value = value.to_i + 1
|
40
|
+
'start-index'
|
41
|
+
when :order
|
42
|
+
all['sortorder'] = 'descending' if params[:descending].nil?
|
43
|
+
'orderby'
|
44
|
+
when :descending
|
45
|
+
value = value ? 'descending' : 'ascending'
|
46
|
+
'sortorder'
|
47
|
+
when :updated_after
|
48
|
+
value = value.strftime("%Y-%m-%dT%H:%M:%S%Z") if value.respond_to? :strftime
|
49
|
+
'updated-min'
|
50
|
+
else key
|
51
|
+
end
|
52
|
+
|
53
|
+
all[key] = value
|
54
|
+
end
|
55
|
+
all
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_contacts(body)
|
60
|
+
document = Nokogiri::XML(body)
|
61
|
+
document.search('/xmlns:feed/xmlns:entry').map do |entry|
|
62
|
+
emails = entry.search('./gd:email[@address]').map{|e| e['address'].to_s}
|
63
|
+
next if emails.empty?
|
64
|
+
title = entry.at('title') and
|
65
|
+
name = title.inner_text
|
66
|
+
Contact.new(name, emails)
|
67
|
+
end.compact
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'oauth'
|
2
|
+
|
3
|
+
module Contacts
|
4
|
+
class OAuthConsumer < Consumer
|
5
|
+
configuration_attribute :consumer_key
|
6
|
+
configuration_attribute :consumer_secret
|
7
|
+
|
8
|
+
def initialize(consumer_options, request_token_params)
|
9
|
+
@consumer_options = consumer_options
|
10
|
+
@request_token_params = request_token_params
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize_serialized(data)
|
14
|
+
value = data['request_token'] and
|
15
|
+
@request_token = deserialize_oauth_token(consumer, value)
|
16
|
+
value = data['access_token'] and
|
17
|
+
@access_token = deserialize_oauth_token(consumer, value)
|
18
|
+
end
|
19
|
+
|
20
|
+
def serializable_data
|
21
|
+
data = {}
|
22
|
+
data['access_token'] = serialize_oauth_token(@access_token) if @access_token
|
23
|
+
data['request_token'] = serialize_oauth_token(@request_token) if @request_token
|
24
|
+
data
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_accessor :request_token
|
28
|
+
attr_accessor :access_token
|
29
|
+
|
30
|
+
def authentication_url(target, options={})
|
31
|
+
@request_token = consumer.get_request_token({:oauth_callback => target}, @request_token_params)
|
32
|
+
@request_token.authorize_url
|
33
|
+
end
|
34
|
+
|
35
|
+
def authorize(params)
|
36
|
+
begin
|
37
|
+
@access_token = @request_token.get_access_token(:oauth_verifier => params['oauth_verifier'])
|
38
|
+
rescue OAuth::Unauthorized => error
|
39
|
+
@error = error.message
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def consumer
|
46
|
+
@consumer ||= OAuth::Consumer.new(consumer_key, consumer_secret, @consumer_options)
|
47
|
+
end
|
48
|
+
|
49
|
+
#
|
50
|
+
# Marshal sucks for persistence. This provides a prettier, more
|
51
|
+
# future-proof persistable representation of a token.
|
52
|
+
#
|
53
|
+
def serialize_oauth_token(token)
|
54
|
+
params = {
|
55
|
+
'version' => '1', # serialization format
|
56
|
+
'type' => token.is_a?(OAuth::AccessToken) ? 'access' : 'request',
|
57
|
+
'oauth_token' => token.token,
|
58
|
+
'oauth_token_secret' => token.secret,
|
59
|
+
}
|
60
|
+
params_to_query(params)
|
61
|
+
end
|
62
|
+
|
63
|
+
def deserialize_oauth_token(consumer, data)
|
64
|
+
params = query_to_params(data)
|
65
|
+
klass = params['type'] == 'access' ? OAuth::AccessToken : OAuth::RequestToken
|
66
|
+
token = klass.new(consumer, params.delete('oauth_token'), params.delete('oauth_token_secret'))
|
67
|
+
token
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Contacts
|
2
|
+
module Util
|
3
|
+
#
|
4
|
+
# Freeze the given hash, and any hash values recursively.
|
5
|
+
#
|
6
|
+
def self.frozen_hash(hash={})
|
7
|
+
hash.freeze
|
8
|
+
hash.keys.each{|k| k.freeze}
|
9
|
+
hash.values.each{|v| v.freeze}
|
10
|
+
hash
|
11
|
+
end
|
12
|
+
|
13
|
+
#
|
14
|
+
# Return a copy of +hash+ with the keys turned into Symbols.
|
15
|
+
#
|
16
|
+
def self.symbolize_keys(hash)
|
17
|
+
result = {}
|
18
|
+
hash.each do |key, value|
|
19
|
+
result[key.to_sym] = value
|
20
|
+
end
|
21
|
+
result
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
|
3
|
+
module Contacts
|
4
|
+
class WindowsLive < Consumer
|
5
|
+
configuration_attribute :application_id
|
6
|
+
configuration_attribute :secret_key
|
7
|
+
configuration_attribute :privacy_policy_url
|
8
|
+
|
9
|
+
#
|
10
|
+
# If this is set, then #authentication_url will force the given
|
11
|
+
# +target+ URL to have this origin (= scheme + host + port). This
|
12
|
+
# should match the domain that your Windows Live project is
|
13
|
+
# configured to live on.
|
14
|
+
#
|
15
|
+
# Instead of calling #authorize(params) when the user returns, you
|
16
|
+
# will need to call #forced_redirect_url(params) to redirect the
|
17
|
+
# user to the true contacts handler. #forced_redirect_url will
|
18
|
+
# handle construction of the query string based on the incoming
|
19
|
+
# parameters.
|
20
|
+
#
|
21
|
+
# The intended use is for development mode on localhost, which
|
22
|
+
# Windows Live forbids redirection to. Instead, you may register
|
23
|
+
# your app to live on "http://myapp.local", set :force_origin =>
|
24
|
+
# 'http://myapp.local:3000', and map the domain to 127.0.0.1 via
|
25
|
+
# your local hosts file. Your handlers will then look something
|
26
|
+
# like:
|
27
|
+
#
|
28
|
+
# def handler
|
29
|
+
# if ENV['HTTP_METHOD'] == 'POST'
|
30
|
+
# consumer = Contacts::WindowsLive.new
|
31
|
+
# redirect_to consumer.authentication_url(session)
|
32
|
+
# else
|
33
|
+
# consumer = Contacts::WindowsLive.deserialize(session[:consumer])
|
34
|
+
# consumer.authorize(params)
|
35
|
+
# contacts = consumer.contacts
|
36
|
+
# end
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# Since only the origin is forced -- not the path part of the URL
|
40
|
+
# -- the handler typically redirects to itself. The second time
|
41
|
+
# through it is a GET request.
|
42
|
+
#
|
43
|
+
# Default: nil
|
44
|
+
#
|
45
|
+
# Example: http://myapp.local
|
46
|
+
#
|
47
|
+
configuration_attribute :force_origin
|
48
|
+
|
49
|
+
def initialize(options={})
|
50
|
+
@token_expires_at = nil
|
51
|
+
@location_id = nil
|
52
|
+
@delegation_token = nil
|
53
|
+
end
|
54
|
+
|
55
|
+
def initialize_serialized(data)
|
56
|
+
@token_expires_at = Time.at(data['token_expires_at'].to_i)
|
57
|
+
@location_id = data['location_id']
|
58
|
+
@delegation_token = data['delegation_token']
|
59
|
+
end
|
60
|
+
|
61
|
+
def serializable_data
|
62
|
+
data = {}
|
63
|
+
data['token_expires_at'] = @token_expires_at.to_i if @token_expires_at
|
64
|
+
data['location_id'] = @location_id if @location_id
|
65
|
+
data['delegation_token'] = @delegation_token if @delegation_token
|
66
|
+
data
|
67
|
+
end
|
68
|
+
|
69
|
+
def authentication_url(target, options={})
|
70
|
+
if force_origin
|
71
|
+
context = target
|
72
|
+
target = force_origin + URI.parse(target).path
|
73
|
+
end
|
74
|
+
|
75
|
+
url = "https://consent.live.com/Delegation.aspx"
|
76
|
+
query = {
|
77
|
+
'ps' => 'Contacts.Invite',
|
78
|
+
'ru' => target,
|
79
|
+
'pl' => privacy_policy_url,
|
80
|
+
'app' => app_verifier,
|
81
|
+
}
|
82
|
+
query['appctx'] = context if context
|
83
|
+
"#{url}?#{params_to_query(query)}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def forced_redirect_url(params)
|
87
|
+
target_origin = params['appctx'] and
|
88
|
+
"#{target_origin}?#{params_to_query(params)}"
|
89
|
+
end
|
90
|
+
|
91
|
+
def authorize(params)
|
92
|
+
consent_token_data = params['ConsentToken'] or
|
93
|
+
raise Error, "no ConsentToken from Windows Live"
|
94
|
+
eact = backwards_query_to_params(consent_token_data)['eact'] or
|
95
|
+
raise Error, "missing eact from Windows Live"
|
96
|
+
query = decode_eact(eact)
|
97
|
+
consent_authentic?(query) or
|
98
|
+
raise Error, "inauthentic Windows Live consent"
|
99
|
+
params = query_to_params(query)
|
100
|
+
@token_expires_at = Time.at(params['exp'].to_i)
|
101
|
+
@location_id = params['lid']
|
102
|
+
@delegation_token = params['delt']
|
103
|
+
true
|
104
|
+
rescue Error => error
|
105
|
+
@error = error.message
|
106
|
+
false
|
107
|
+
end
|
108
|
+
|
109
|
+
def contacts(options={})
|
110
|
+
return nil if @delegation_token.nil? || @token_expires_at < Time.now
|
111
|
+
# TODO: Handle expired token.
|
112
|
+
xml = request_contacts
|
113
|
+
parse_xml(xml)
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def signature_key
|
119
|
+
OpenSSL::Digest::SHA256.digest("SIGNATURE#{secret_key}")[0...16]
|
120
|
+
end
|
121
|
+
|
122
|
+
def encryption_key
|
123
|
+
OpenSSL::Digest::SHA256.digest("ENCRYPTION#{secret_key}")[0...16]
|
124
|
+
end
|
125
|
+
|
126
|
+
def app_verifier
|
127
|
+
token = params_to_query({
|
128
|
+
'appid' => application_id,
|
129
|
+
'ts' => Time.now.to_i,
|
130
|
+
})
|
131
|
+
token << "&sig=#{CGI.escape(Base64.encode64(sign(token)))}"
|
132
|
+
end
|
133
|
+
|
134
|
+
def sign(token)
|
135
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, signature_key, token)
|
136
|
+
end
|
137
|
+
|
138
|
+
def decode_eact(eact)
|
139
|
+
token = Base64.decode64(CGI.unescape(eact))
|
140
|
+
iv, crypted = token[0...16], token[16..-1]
|
141
|
+
cipher = OpenSSL::Cipher::AES128.new("CBC")
|
142
|
+
cipher.decrypt
|
143
|
+
cipher.iv = iv
|
144
|
+
cipher.key = encryption_key
|
145
|
+
cipher.update(crypted) + cipher.final
|
146
|
+
end
|
147
|
+
|
148
|
+
def consent_authentic?(query)
|
149
|
+
body, encoded_signature = query.split(/&sig=/)
|
150
|
+
signature = Base64.decode64(CGI.unescape(encoded_signature))
|
151
|
+
sign(body) == signature
|
152
|
+
end
|
153
|
+
|
154
|
+
#
|
155
|
+
# Like #query_to_params, but do the unescaping *before* the
|
156
|
+
# splitting on '&' and '=', like Microsoft does it.
|
157
|
+
#
|
158
|
+
def backwards_query_to_params(data)
|
159
|
+
params={}
|
160
|
+
CGI.unescape(data).split(/&/).each do |pair|
|
161
|
+
key, value = *pair.split(/=/)
|
162
|
+
params[key] = value ? value : ''
|
163
|
+
end
|
164
|
+
params
|
165
|
+
end
|
166
|
+
|
167
|
+
def request_contacts
|
168
|
+
http = Net::HTTP.new('livecontacts.services.live.com', 443)
|
169
|
+
http.use_ssl = true
|
170
|
+
url = "/users/@L@#{@location_id}/rest/invitationsbyemail"
|
171
|
+
authorization = "DelegatedToken dt=\"#{@delegation_token}\""
|
172
|
+
http.get(url, {"Authorization" => authorization}).body
|
173
|
+
end
|
174
|
+
|
175
|
+
def parse_xml(xml)
|
176
|
+
document = Nokogiri::XML(xml)
|
177
|
+
document.search('/LiveContacts/Contacts/Contact').map do |contact|
|
178
|
+
email = contact.at('PreferredEmail').inner_text.strip
|
179
|
+
names = []
|
180
|
+
element = contact.at('Profiles/Personal/FirstName') and
|
181
|
+
names << element.inner_text.strip
|
182
|
+
element = contact.at('Profiles/Personal/LastName') and
|
183
|
+
names << element.inner_text.strip
|
184
|
+
Contact.new(email, names.join(' '))
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|