omnicontacts 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ coverage
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,39 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ omnicontacts (0.1.0)
5
+ json
6
+ rack
7
+
8
+ GEM
9
+ remote: http://rubygems.org/
10
+ specs:
11
+ diff-lcs (1.1.3)
12
+ json (1.6.5)
13
+ multi_json (1.1.0)
14
+ rack (1.4.1)
15
+ rack-test (0.6.1)
16
+ rack (>= 1.0)
17
+ rake (0.9.2.2)
18
+ rspec (2.8.0)
19
+ rspec-core (~> 2.8.0)
20
+ rspec-expectations (~> 2.8.0)
21
+ rspec-mocks (~> 2.8.0)
22
+ rspec-core (2.8.0)
23
+ rspec-expectations (2.8.0)
24
+ diff-lcs (~> 1.1.2)
25
+ rspec-mocks (2.8.0)
26
+ simplecov (0.6.1)
27
+ multi_json (~> 1.0)
28
+ simplecov-html (~> 0.5.3)
29
+ simplecov-html (0.5.3)
30
+
31
+ PLATFORMS
32
+ ruby
33
+
34
+ DEPENDENCIES
35
+ omnicontacts!
36
+ rack-test
37
+ rake
38
+ rspec
39
+ simplecov
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ = OmniContacts
2
+
3
+ Inspired by the popular OmniAuth, OmniContacts is a library that enables users of an application to import contacts from their email accounts.
4
+ The current version allows to import contacts from the three most popular web email providers: Gmail, Yahoo and Hotmail.
5
+ OmniContacts is a Rack middleware, therefore you can use it with Rails, Sinatra and with any other Rack-based framework.
6
+
7
+ OmniContacts uses the OAuth protocol to communicate with the contacts provider. Yahoo still uses OAuth 1.0, while both Gmail and Hotmail support OAuth 2.0.
8
+ In order to use OmniContacts, it is therefore necessary to first register your application with the providers you want to use and to obtain client_id and client_secret.
9
+
10
+ == Usage
11
+
12
+ Add OmniContacts as a dependency:
13
+ ```ruby
14
+ gem "omnicontacts"
15
+ ```
16
+
17
+ As for OmniAuth, there is a Builder facilitating the usage of multiple contacts importers. In the case of a Rails application, the following code could be placed at `config/initializers/omnicontacts.rb`:
18
+
19
+ ```ruby
20
+ require "omnicontacts"
21
+
22
+ Rails.application.middleware.use OmniContacts::Builder do
23
+ importer :gmail, "client_id", "client_secret", {:redirect_path => "/oauth2callback", :ssl_ca_file => "/etc/ssl/certs/curl-ca-bundle.crt"}
24
+ importer :yahoo, "consumer_id", "consumer_secret", {:callback_path => '/callback'}
25
+ importer :hotmail, "client_id", "client_secret"
26
+ end
27
+
28
+ ```
29
+
30
+ Every importer expects `client_id` and `client_secret` as mandatory, while `:redirect_path` and `:ssl_ca_file` are optional.
31
+ Since Yahoo implements the version 1.0 of the OAuth protocol, naming is slightly different. Instead of `:redirect_path` you should use `:callback_path` as key in the hash providing the optional parameters.
32
+ While `:ssl_ca_file` is optional, it is highly recommended to set it on production environments for obvious security reasons.
33
+ On the other hand it makes things much easier to leave the default value for `:redirect_path` and `:callback path`, the reason of which will be clear after reading the following section.
34
+
35
+ == Integrating with your Application
36
+
37
+ To use OmniContacts you only need to redirect users to `/contacts/:importer`, where `:importer` can be google, yahoo or hotmail. Once the user has authorized your application, he will be redirected back to your website, to the path specified in `:redirect_path` (or `:callback_path` for yahoo). The user is redirected to `/contacts/:importer/callback` by default, which therefore makes things much simpler to not specify any value for `:redirect_path` or `:callback_path`.
38
+ The list of contacts can be accessed via the `omnicontacts.contacts` key in the environment hash. The list of contacts is a simple array of hashes. Each hash has two keys: `:email` and `:name`, containing the email and the name of the contact respectively.
39
+
40
+ ```ruby
41
+ def contacts_callback
42
+ @contacts = request.env['omnicontacts.contacts']
43
+ puts "List of contacts obtained from #{params[:importer]}:"
44
+ @contacts.each do |contact|
45
+ puts "Contact found: name => #{contact[:name]}, email => #{contact[:email]}"
46
+ end
47
+ end
48
+ ```
49
+
50
+ If the user does not authorize your application to access his/her contacts list, or any other inconvenience occurs, he/she is redirected to `/contacts/failure`. The query string will contain a parameter named `error_message` which specifies why the list of contacts could not be retrieved. `error_message` can have one of the following values: `not_authorized`, `timeout` and `internal_error`.
51
+
52
+ == Tips and tricks
53
+
54
+ OmniContacts supports OAuth 1.0 and OAuth 2.0 token refresh, but for both it needs to persist data between requests. OmniContacts stores access tokens in the session. If you hit the 4KB cookie storage limit you better opt for the Memcache or the Active Record storage.
55
+
56
+ Gmail requires you to register the redirect_path on their website along with your application. Make sure to use the same value present in the configuration file, or `/contacts/gmail/callback` if using the default.
57
+
58
+ Yahoo requires you to configure the Permissions your application requires. Make sure to go the Yahoo website and to select Read permission for Contacts.
59
+
60
+ Hotmail does not accept requests from localhost. This can be quite annoying during development, but unfortunately this is the way it is.
61
+ Hotmail presents another "peculiar" feature. Their API returns a Contact object, which does not contain an e-mail field! However, if the contact has either name, family name or both set to null, than there is a field called name which does contain the e-mail address. To summarize, a Hotmail contact will only be returned if the name field contains a valid e-mail address, otherwise it will be skipped. Another consequence is that OmniContacts can provide contacts with only the `:email` key set.
62
+
63
+ ## License
64
+
65
+ Copyright (c) 2012 Diego81
66
+
67
+ Permission is hereby granted, free of charge, to any person obtaining a
68
+ copy of this software and associated documentation files (the "Software"),
69
+ to deal in the Software without restriction, including without limitation
70
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
71
+ and/or sell copies of the Software, and to permit persons to whom the
72
+ Software is furnished to do so, subject to the following conditions:
73
+
74
+ The above copyright notice and this permission notice shall be included
75
+ in all copies or substantial portions of the Software.
76
+
77
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
78
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
79
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
80
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
81
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
82
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
83
+ DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
6
+ task :test => :spec
@@ -0,0 +1,12 @@
1
+ require "rack"
2
+
3
+ module OmniContacts
4
+
5
+ VERSION = "0.1.0"
6
+
7
+ autoload :Builder, "omnicontacts/builder"
8
+ autoload :Importer, "omnicontacts/importer"
9
+
10
+ class AuthorizationError < RuntimeError
11
+ end
12
+ end
@@ -0,0 +1,122 @@
1
+ require "omnicontacts/http_utils"
2
+ require "base64"
3
+
4
+ # This module represent a OAuth 1.0 Client.
5
+ #
6
+ # Classes including the module must implement
7
+ # the following methods:
8
+ # * auth_host -> the host of the authorization server
9
+ # * auth_token_path -> the path to query to obtain a request token
10
+ # * consumer_key -> the registered consumer key of the client
11
+ # * consumer_secret -> the registered consumer secret of the client
12
+ # * callback -> the callback to include during the redirection step
13
+ # * auth_path -> the path on the authorization server to redirect the user to
14
+ # * access_token_path -> the path to query in order to obtain the access token
15
+ module OmniContacts
16
+ module Authorization
17
+ module OAuth1
18
+ include HTTPUtils
19
+
20
+ OAUTH_VERSION = "1.0"
21
+
22
+ # Obtain an authorization token from the server.
23
+ # The token is returned in an array along with the relative authorization token secret.
24
+ def fetch_authorization_token
25
+ request_token_response = https_post(auth_host, auth_token_path, request_token_req_params)
26
+ values_from_query_string(request_token_response, ["oauth_token", "oauth_token_secret"])
27
+ end
28
+
29
+ private
30
+
31
+ def request_token_req_params
32
+ {
33
+ :oauth_consumer_key => consumer_key,
34
+ :oauth_nonce => encode(random_string),
35
+ :oauth_signature_method => "PLAINTEXT",
36
+ :oauth_signature => encode(consumer_secret + "&"),
37
+ :oauth_timestamp => timestamp,
38
+ :oauth_version => OAUTH_VERSION,
39
+ :oauth_callback => callback
40
+ }
41
+ end
42
+
43
+ def random_string
44
+ (0...50).map{ ('a'..'z').to_a[rand(26)] }.join
45
+ end
46
+
47
+ def timestamp
48
+ Time.now.to_i.to_s
49
+ end
50
+
51
+ def values_from_query_string query_string, keys_to_extract
52
+ map = query_string_to_map(query_string)
53
+ keys_to_extract.collect do |key|
54
+ if map.has_key?(key)
55
+ map[key]
56
+ else
57
+ raise "No value found for #{key} in #{query_string}"
58
+ end
59
+ end
60
+ end
61
+
62
+ public
63
+
64
+ # Returns the url the user has to be redirected to do in order grant permission to the client application.
65
+ def authorization_url auth_token
66
+ "https://" + auth_host + auth_path + "?oauth_token=" + auth_token
67
+ end
68
+
69
+ # Fetches the access token from the authorization server.
70
+ # The method expects the authorization token, the authorization token secret and the authorization verifier.
71
+ # The result comprises the access token, the access token secret and a list of additional fields extracted from the server's response.
72
+ # The list of additional fields to extract is specified as last parameter
73
+ def fetch_access_token auth_token, auth_token_secret, auth_verifier, additional_fields_to_extract = []
74
+ access_token_resp = https_post(auth_host, access_token_path, access_token_req_params(auth_token, auth_token_secret, auth_verifier))
75
+ values_from_query_string(access_token_resp, ( ["oauth_token", "oauth_token_secret"] + additional_fields_to_extract) )
76
+ end
77
+
78
+ private
79
+
80
+ def access_token_req_params auth_token, auth_token_secret, auth_verifier
81
+ {
82
+ :oauth_consumer_key => consumer_key,
83
+ :oauth_nonce => encode(random_string),
84
+ :oauth_signature_method => "PLAINTEXT",
85
+ :oauth_signature => encode(consumer_secret + "&" + auth_token_secret),
86
+ :oauth_version => OAUTH_VERSION,
87
+ :oauth_timestamp => timestamp,
88
+ :oauth_token => auth_token,
89
+ :oauth_verifier => auth_verifier
90
+ }
91
+ end
92
+
93
+ public
94
+
95
+ # Calculates a signature using HMAC-SHA1 according to the OAuth 1.0 specifications.
96
+ #
97
+ # The base string is given is a RFC 3986 encoded concatenation of:
98
+ # * Uppercase HTTP method
99
+ # * An '&'
100
+ # * A url without any parameters
101
+ # * An '&'
102
+ # * All parameters to use in the request encoded themselves and sorted by key.
103
+ #
104
+ # The signature key is given by the concatenation of:
105
+ # * RFC 3986 encoded consumer secret
106
+ # * An '&'
107
+ # * RFC 3986 encoded token secret
108
+ def oauth_signature method, url, params, secret
109
+ encoded_method = encode(method.upcase)
110
+ encoded_url = encode(url)
111
+ # params must be in alphabetical order
112
+ encoded_params = encode(to_query_string(params.sort))
113
+ base_string = encoded_method + '&' + encoded_url + '&' + encoded_params
114
+ key = encode(consumer_secret) + '&' + secret
115
+ hmac_sha1 = OpenSSL::HMAC.digest('sha1', key, base_string)
116
+ # base64 encode results must be stripped
117
+ encode(Base64.encode64(hmac_sha1).strip)
118
+ end
119
+
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,84 @@
1
+ require "omnicontacts/http_utils"
2
+ require "json"
3
+
4
+ # This module represents an OAuth 2.0 client.
5
+ #
6
+ # Classes including the module must implement
7
+ # the following methods:
8
+ # * auth_host -> the host of the authorization server
9
+ # * authorize_path -> the path on the authorization server the redirect the use to
10
+ # * client_id -> the registered client id of the client
11
+ # * client_secret -> the registered client secret of the client
12
+ # * redirect_path -> the path the authorization server has to redirect the user back after authorization
13
+ # * auth_token_path -> the path to query once the user has granted permission to the application
14
+ # * scope -> the scope necessary to acquire the contacts list.
15
+ module OmniContacts
16
+ module Authorization
17
+ module OAuth2
18
+ include HTTPUtils
19
+
20
+ # Calculates the URL the user has to be redirected to in order to authorize
21
+ # the application to access his contacts list.
22
+ def authorization_url
23
+ "https://" + auth_host + authorize_path + "?" + authorize_url_params
24
+ end
25
+
26
+ private
27
+
28
+ def authorize_url_params
29
+ to_query_string({
30
+ :client_id => client_id,
31
+ :scope => encode(scope),
32
+ :response_type => "code",
33
+ :access_type => "offline",
34
+ :approval_prompt => "force",
35
+ :redirect_uri => encode(redirect_uri)
36
+ })
37
+ end
38
+
39
+ public
40
+
41
+ # Fetches the access token from the authorization server using the given authorization code.
42
+ def fetch_access_token code
43
+ access_token_from_response https_post(auth_host, auth_token_path, token_req_params(code))
44
+ end
45
+
46
+ private
47
+
48
+ def token_req_params code
49
+ {
50
+ :client_id => client_id,
51
+ :client_secret => client_secret,
52
+ :code => code,
53
+ :redirect_uri => encode(redirect_uri),
54
+ :grant_type => "authorization_code"
55
+ }
56
+ end
57
+
58
+ def access_token_from_response response
59
+ json = JSON.parse(response)
60
+ raise json["error"] if json["error"]
61
+ [ json["access_token"], json["token_type"], json["refresh_token"] ]
62
+ end
63
+
64
+ public
65
+
66
+ # Refreshes the access token using the provided refresh_token.
67
+ def refresh_access_token refresh_token
68
+ access_token_from_response https_post(auth_host, auth_token_path, refresh_token_req_params(refresh_token))
69
+ end
70
+
71
+ private
72
+
73
+ def refresh_token_req_params refresh_token
74
+ {
75
+ :client_id => client_id,
76
+ :client_secret => client_secret,
77
+ :refresh_token => refresh_token,
78
+ :grant_type => "refresh_token"
79
+ }
80
+
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,30 @@
1
+ require "omnicontacts"
2
+
3
+ module OmniContacts
4
+ class Builder < Rack::Builder
5
+ def initialize(app,&block)
6
+ if rack14?
7
+ super
8
+ else
9
+ @app = app
10
+ super(&block)
11
+ end
12
+ end
13
+
14
+ def rack14?
15
+ Rack.release.split('.')[1].to_i >= 4
16
+ end
17
+
18
+ def importer importer, *args
19
+ middleware = OmniContacts::Importer.const_get(importer.to_s.capitalize)
20
+ use middleware, *args
21
+ rescue NameError
22
+ raise LoadError, "Could not find importer #{importer}."
23
+ end
24
+
25
+ def call env
26
+ @ins << @app unless @ins.include?(@app)
27
+ to_app.call(env)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,88 @@
1
+ require "net/http"
2
+ require "cgi"
3
+ require "openssl"
4
+
5
+ # This module contains a set of utility methods related to the HTTP protocol.
6
+ module OmniContacts
7
+ module HTTPUtils
8
+
9
+ SSL_PORT = 443
10
+
11
+ module_function
12
+
13
+ def query_string_to_map query_string
14
+ query_string.split('&').reduce({}) do |memo, key_value|
15
+ (key,value) = key_value.split('=')
16
+ memo[key]= value
17
+ memo
18
+ end
19
+ end
20
+
21
+ def to_query_string map
22
+ map.collect do |key, value|
23
+ key.to_s + "=" + value
24
+ end.join("&")
25
+ end
26
+
27
+ # Encodes the given input according to RFC 3986
28
+ def encode to_encode
29
+ CGI.escape(to_encode)
30
+ end
31
+
32
+ # Calculates the url of the host from a Rack environment.
33
+ # The result is in the form scheme://host:port
34
+ # If port is 80 the result is scheme://host
35
+ # According to Rack specification the HTTP_HOST variable is preferred over SERVER_NAME.
36
+ def host_url_from_rack_env env
37
+ port = ( (env["SERVER_PORT"] == 80) && "") || ":#{env['SERVER_PORT']}"
38
+ host = (env["HTTP_HOST"]) || (env["SERVER_NAME"] + port)
39
+ env["rack.url_scheme"] + "://" + host
40
+ end
41
+
42
+ # Classes including the module must respond to the ssl_ca_file message in order to use the following methods.
43
+ # The response will be the path to the CA file to use when making https requests.
44
+ # If the result of ssl_ca_file is nil no file is used. In this case a warn message is logged.
45
+ private
46
+
47
+ # Executes an HTTP GET request.
48
+ # It raises a RuntimeError if the response code is not equal to 200
49
+ def http_get host, path, params
50
+ connection = Net::HTTP.new(host)
51
+ process_http_response connection.request_get(path + "?" + to_query_string(params))
52
+ end
53
+
54
+ # Executes an HTTP POST request over SSL
55
+ # It raises a RuntimeError if the response code is not equal to 200
56
+ def https_post host,path, params
57
+ https_connection host do |connection|
58
+ connection.request_post(path, to_query_string(params))
59
+ end
60
+ end
61
+
62
+ # Executes an HTTP GET request over SSL
63
+ # It raises a RuntimeError if the response code is not equal to 200
64
+ def https_get host, path, params, headers =[]
65
+ https_connection host do |connection|
66
+ connection.request_get(path + "?" + to_query_string(params), headers)
67
+ end
68
+ end
69
+
70
+ def https_connection (host)
71
+ connection = Net::HTTP.new(host, SSL_PORT)
72
+ connection.use_ssl = true
73
+ if ssl_ca_file
74
+ connection.ca_file = ssl_ca_file
75
+ else
76
+ logger << "No SSL ca file provided. It is highly reccomended to use one in production envinronments" if respond_to?(:logger) && logger
77
+ connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
78
+ end
79
+ process_http_response(yield(connection))
80
+ end
81
+
82
+ def process_http_response response
83
+ raise response.body if response.code != "200"
84
+ response.body
85
+ end
86
+
87
+ end
88
+ end