tozny-auth 0.1.6 → 0.1.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cfe8301a7185fc15b5db4e783ef36c19c148d3aa
4
- data.tar.gz: e3ee082717e5820b05944c7bbb5fbbf667416eb2
3
+ metadata.gz: e41382cfb8a2420e58e304d53d9750c71b7046de
4
+ data.tar.gz: b6dc52cfddc4209c57202ccaa3110548fbb8e560
5
5
  SHA512:
6
- metadata.gz: a7f9300e08f52fd63a83b19ccfebfbcf84fdf0b0e3bd8335781043fa3da6b3b857e4d64a17d4bf04b2651edc1ed0ea66ce33ef3b252ed718f75a241efa17e4eb
7
- data.tar.gz: 3dfef0a874d67a6415b564ddee401995176e5b2a2cc48b2fdf706ced4eb7a9fd13b99d64679957050fbfe22833f7c67352a453120a02fa2f79f52080e9dd1c04
6
+ metadata.gz: 3efa4daf484e74ad5a7fe404b134e804025f8cc481973b42ce174848d9b6854b46f22682fa3583b1a3f65732b72d5d82219ea908d702ce0e2298f1beae21d610
7
+ data.tar.gz: ba7dbf7c62dcfddb841bfa0c71b1b7224a083e0b392fc3c744b558907c0d1c51f472b927e280a372154734c31feaf28e9deb1a44aa55aff77c6d723cc20e27bd
@@ -1,8 +1,8 @@
1
- Metrics/LineLength:
2
- Enabled: false
3
- Metrics/MethodLength:
4
- Enabled: false
5
- Rails:
6
- Enabled: false
7
- Style/SpaceInsideHashLiteralBraces:
1
+ Metrics/LineLength:
2
+ Enabled: false
3
+ Metrics/MethodLength:
4
+ Enabled: false
5
+ Rails:
6
+ Enabled: false
7
+ Style/SpaceInsideHashLiteralBraces:
8
8
  Enabled: false
@@ -1,14 +1,14 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "bundler/setup"
4
- require "tozny/auth"
5
-
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "tozny/auth"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup CHANGED
@@ -1,8 +1,8 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -1,64 +1,64 @@
1
- require 'openssl'
2
- require 'base64'
3
- require 'json'
4
- require 'securerandom'
5
-
6
- module Tozny
7
- # utility class for tozny-specific cryptography and encoding
8
- class Core
9
-
10
- # encodes a string according to the base64url specification, including removing padding
11
- # @param [String] str the string to encode
12
- # @return [String] the base64url-encoded string
13
- def self.base64url_encode(str)
14
- Base64::strict_encode64(
15
- str
16
- )
17
- .tr('+/', '-_')
18
- .tr('=', '')
19
- end
20
-
21
- # decodes a padding-stripped base64url string
22
- # @param [String] str the base64url-encoded string
23
- # @return [String] the decoded plaintext string
24
- def self.base64url_decode(str)
25
- Base64::strict_decode64(
26
- str.tr('-_', '+/')
27
- .ljust(str.length+(str.length % 4), '='))
28
- # replace - with + and _ with /
29
- # then add padding
30
- end
31
-
32
- # checks the HMAC/SHA256 signature of a string
33
- # @param [String] signature the signature to check against
34
- # @param [String] str the signed data to check the signature against
35
- # @param [String] secret the secret to check the signature against
36
- # @return [TrueClass, FalseClass] whether the signature matched
37
- def self.check_signature(signature, str, secret)
38
- expected_sig = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, str)
39
- expected_sig == signature
40
- end
41
-
42
- # base64url encodes and signs some data
43
- # * yields a base64url-encoded data object AND base64url-encoded signature
44
- # * the signature signs the base64-encoded data, NOT the raw data
45
- # @param [String] data the raw data to be encoded
46
- # @param [String] secret the secret to sign the encoded data with
47
- # @return [Hash] a hash including the signed_data and a signature
48
- def self.encode_and_sign(data, secret)
49
- encoded_data = base64url_encode(data)
50
- sig = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), secret, encoded_data)
51
- encoded_sig = base64url_encode(sig)
52
- {
53
- signed_data: encoded_data,
54
- signature: encoded_sig
55
- }
56
- end
57
-
58
- # generates a nonce (number used once)
59
- # @return [String] a hexadecimal nonce
60
- def self.generate_nonce
61
- OpenSSL::Digest::SHA256.hexdigest SecureRandom.random_bytes(8)
62
- end
63
- end
64
- end
1
+ require 'openssl'
2
+ require 'base64'
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Tozny
7
+ # utility class for tozny-specific cryptography and encoding
8
+ class Core
9
+
10
+ # encodes a string according to the base64url specification, including removing padding
11
+ # @param [String] str the string to encode
12
+ # @return [String] the base64url-encoded string
13
+ def self.base64url_encode(str)
14
+ Base64::strict_encode64(
15
+ str
16
+ )
17
+ .tr('+/', '-_')
18
+ .tr('=', '')
19
+ end
20
+
21
+ # decodes a padding-stripped base64url string
22
+ # @param [String] str the base64url-encoded string
23
+ # @return [String] the decoded plaintext string
24
+ def self.base64url_decode(str)
25
+ Base64::strict_decode64(
26
+ str.tr('-_', '+/')
27
+ .ljust(str.length+(str.length % 4), '='))
28
+ # replace - with + and _ with /
29
+ # then add padding
30
+ end
31
+
32
+ # checks the HMAC/SHA256 signature of a string
33
+ # @param [String] signature the signature to check against
34
+ # @param [String] str the signed data to check the signature against
35
+ # @param [String] secret the secret to check the signature against
36
+ # @return [TrueClass, FalseClass] whether the signature matched
37
+ def self.check_signature(signature, str, secret)
38
+ expected_sig = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, str)
39
+ expected_sig == signature
40
+ end
41
+
42
+ # base64url encodes and signs some data
43
+ # * yields a base64url-encoded data object AND base64url-encoded signature
44
+ # * the signature signs the base64-encoded data, NOT the raw data
45
+ # @param [String] data the raw data to be encoded
46
+ # @param [String] secret the secret to sign the encoded data with
47
+ # @return [Hash] a hash including the signed_data and a signature
48
+ def self.encode_and_sign(data, secret)
49
+ encoded_data = base64url_encode(data)
50
+ sig = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), secret, encoded_data)
51
+ encoded_sig = base64url_encode(sig)
52
+ {
53
+ signed_data: encoded_data,
54
+ signature: encoded_sig
55
+ }
56
+ end
57
+
58
+ # generates a nonce (number used once)
59
+ # @return [String] a hexadecimal nonce
60
+ def self.generate_nonce
61
+ OpenSSL::Digest::SHA256.hexdigest SecureRandom.random_bytes(8)
62
+ end
63
+ end
64
+ end
@@ -1,5 +1,5 @@
1
1
  module Tozny
2
2
  module Auth
3
- VERSION = '0.1.6'
3
+ VERSION = '0.1.7'
4
4
  end
5
5
  end
@@ -1,274 +1,303 @@
1
- require 'tozny/user'
2
- require 'json'
3
- require 'net/http'
4
- require 'uri'
5
-
6
- # rubocop:disable Metrics/PerceivedComplexity
7
-
8
- module Tozny
9
- class Realm
10
- attr_accessor :realm_key_id, :realm_secret, :api_url, :user_api
11
-
12
- def initialize(realm_key_id, realm_secret, api_url = nil)
13
- # set the API URL
14
- if !api_url.nil?
15
- self.api_url = api_url
16
- elsif !(ENV['API_URL'].nil?)
17
- self.api_url = ENV['API_URL']
18
- else
19
- self.api_url = 'https://api.tozny.com/index.php'
20
- end
21
- unless self.api_url.is_a? URI # don't try to parse a URI instance into a URI, as this will break
22
- self.api_url = URI.parse(self.api_url)
23
- end
24
-
25
- set_new_realm(realm_key_id, realm_secret)
26
-
27
- end
28
-
29
- # use a new realm_key_id and realm_secret. updates the user_api handle to reflect this change as well.
30
- # @param [String] realm_key_id
31
- # @param [String] realm_secret
32
- # @return [TrueClass] will always return true
33
- def set_new_realm(realm_key_id, realm_secret)
34
- self.realm_key_id = realm_key_id
35
- self.realm_secret = realm_secret
36
- if user_api.is_a? ::Tozny::User
37
- user_api.set_new_realm(realm_key_id)
38
- else
39
- self.user_api = ::Tozny::User.new(realm_key_id, api_url)
40
- end
41
- true
42
- end
43
-
44
- # verify a login and extract user information from a signed packet forwarded to the server
45
- # @param [String] signed_data the base64URL data to validate
46
- # @param [String] signature the string representation of the signature to check the login with
47
- # @return [Hash, FalseClass] the login information or false if the login did not check out
48
- def check_login_locally(signed_data, signature)
49
- if check_signature(signed_data, signature)
50
- login_info = JSON.parse(::Tozny::Core.base64url_decode(signed_data))
51
- return false if login_info[:expires_at] < Time.now.to_i
52
- login_info
53
- else
54
- false
55
- end
56
- end
57
-
58
- # verify a login from a user and session id. Does not return complete login information.
59
- # @param [String] user_id the user_id of the login to check
60
- # @param [String] session_id the session_id of the login to check
61
- # @return [Hash] the return from the API
62
- def check_login_via_api(user_id, session_id) # NOTE: this only returns true/false. You need to parse the data locally. See Tozny::Core.base64url_decode
63
- raw_call(
64
- method: 'realm.check_valid_login',
65
- user_id: user_id,
66
- session_id: session_id
67
- )[:return] == 'true'
68
- end
69
-
70
- # Add a user to a closed realm
71
- # @param [String] defer 'true' or 'false', defines whether the user should be deferred to later be completed by the app
72
- # @param [Hash] meta any metadata to be added to the user (eg, favorite color or mother's home address). All meta will be stored as strings
73
- # @param [String, OpenSSL::PKey::RSA] pub_key the public key of the user to be added. Only necessary
74
- # @return [Hash, FalseClass] the user in its current (incomplete if defer is 'true' state)
75
- # @raise ArgumentError if there is no pubkey when there should be one
76
- def user_add(defer = 'false', meta = nil, pub_key)
77
- unless pub_key.nil?
78
- pub_key = OpenSSL::PKey::RSA.new pub_key if pub_key.is_a? String
79
- pub_key = pub_key.public_key if pub_key.private?
80
- end
81
-
82
- request_obj = {
83
- method: 'realm.user_add',
84
- defer: defer
85
- }
86
- if defer == 'false'
87
- raise ArgumentError, 'Must provide a public key if not using deferred enrollment' if pub_key.nil?
88
- request_obj[:pub_key] = pub_key
89
- end
90
-
91
- unless meta.nil?
92
- request_obj[:extra_fields] = Tozny::Core.base64url_encode(meta.to_json)
93
- end
94
-
95
- user = raw_call request_obj
96
- return false unless user[:return] == 'ok'
97
- user
98
- end
99
-
100
- # update a user's meta fields
101
- # * Note: all meta fields are stored as strings
102
- # @param [String] user_id
103
- # @param [Hash{Symbol,String=>Object}] meta the metadata fields to update, along with their new values
104
- # @return [Hash] the updated user
105
- def user_update(user_id, meta)
106
- raw_call(
107
- method: 'realm.user_update',
108
- user_id: user_id,
109
- extra_fields: Tozny::Core.base64url_encode(meta.to_json)
110
- )
111
- end
112
-
113
- # @param [String] user_id
114
- # @return [Hash] the result of the request to the API
115
- def user_delete(user_id)
116
- raw_call(
117
- method: 'realm.user_delete',
118
- user_id: user_id
119
- )
120
- end
121
-
122
- # retrieve a user's information
123
- # * Note: all meta fields are stored as strings
124
- # @param [String] user_id the id or email (if is_id = false) of the user to get
125
- # @param [TrueClass, FalseClass] is_id true if looking up the user by id, false if looking up by email. defaults to true
126
- # @return [Hash] the user's information
127
- # @raise ArgumentError on failed lookup
128
- def user_get(user_id, is_id = true)
129
- request_obj = {
130
- method: 'realm.user_get'
131
- }
132
- if is_id
133
- request_obj[:user_id] = user_id
134
- else
135
- request_obj[:tozny_email] = user_id
136
- end
137
-
138
- user = raw_call(request_obj)
139
- if user.nil? || user[:results].nil?
140
- raise ArgumentError, ('No user was found for ' + (is_id ? 'id' : 'email') + ': ' + user_id + '.')
141
- end
142
- user[:results]
143
- end
144
-
145
- # performs a device add call
146
- # @param [String] user_id
147
- # @return [Hash] the result of the call: keys include :user_id, :temp_key, :secret_enrollment_url, and :key_id
148
- def user_device_add(user_id)
149
- raw_call(
150
- method: 'realm.user_device_add',
151
- user_id: user_id
152
- )
153
- end
154
-
155
- # create an OOB challenge question session
156
- # @param [Hash<Symbol, String>, String] question either a question hash, as specified by the options, or the text of a question to be presented to the user. Required.
157
- # @option question [String] :question The text of the question to be presented to the user
158
- # @option question [String] :success The URL the user's browser should be redirected to after successful authentication
159
- # @option question [String] :error The URL the user's browser should be redirected to after unsuccessful authentication
160
- # @param [String] success_url The URL the user's browser should be redirected to after successful authentication if not specified in the question object
161
- # @param [String] error_url The URL the user's browser should be redirected to after unsuccessful authentication if not specified in the question object
162
- # @param [String] user_id optional. The user who should answer the question.
163
- # @raise ArgumentError on invalid question type
164
- # TODO: support URI objects instead of strings for success and error
165
- # @return [Hash] the result of the API call
166
- def question_challenge(question, success_url, error_url, user_id)
167
- raise ArgumentError, 'question must either be a string or an options hash as specified' unless (question.is_a?String) || (question.is_a?Hash)
168
- final_question = nil # scope final_question and prevent linting errors
169
- if question.is_a?Hash
170
- final_question = question
171
- final_question[:type] = 'callback'
172
- elsif (success_url.is_a?String) || (error_url.is_a?String)
173
- final_question = {
174
- type: 'callback',
175
- question: question
176
- }
177
- final_question[:success] = success_url if success_url.is_a?String
178
- final_question[:error] = error_url if error_url.is_a?String
179
- else
180
- final_question = {
181
- type: 'question',
182
- question: question
183
- }
184
- end
185
- request_obj = {
186
- method: 'realm.question_challenge',
187
- question: final_question
188
- }
189
- request_obj[:user_id] = user_id if user_id.is_a?String
190
- raw_call request_obj
191
- end
192
-
193
- # Create an OTP challenge session
194
- # @return [Hash] a hash [session_id, presence] containing an OTP session id and an OTP presence (an alias for a type-destination combination)
195
- # @param [String] type one of 'sms-otp-6', 'sms-otp-8': the type of the OTP to send
196
- # @param [String] destination the destination for the OTP. For an SMS OTP, this should be a phone number
197
- # @param [String] presence can be used instead of 'type' and 'destination': an OTP presence provided by the TOZNY API
198
- # @param [Object] data optional passthru data to be added to the signed response on a successful request
199
- # @raise ArgumentError when not enough information to submit an OTP request
200
- # @raise ArgumentError on invalid request type
201
- def otp_challenge(type = nil, destination = nil, presence = nil, data = nil)
202
- raise ArgumentError, 'must provide either a presence or a type and destination' if (type.nil? || destination.nil?) && presence.nil?
203
- request_obj = {
204
- method: 'realm.otp_challenge'
205
- }
206
- unless data.nil?
207
- data = data.to_json unless data.is_a?String
208
- request_obj[:data] = data
209
- end
210
-
211
- if presence.nil?
212
- raise ArgumentError, "request type must one of 'sms-otp-6' or 'sms-otp-8'" unless %w(sms-otp-6 sms-otp-8).include? type
213
- request_obj[:type] = type
214
- # TODO: consider validating that 'destination' is a valid phone number when 'type' is sms-otp-*
215
- request_obj[:destination] = destination
216
- else
217
- request_obj[:presence] = presence
218
- end
219
- raw_call request_obj
220
- end
221
-
222
- # Shorthand method to send an 6-digit OTP via SMS without a presence token
223
- # @param destination @see(otp_challenge)
224
- # @param data @see(otp_challenge)
225
- # @return @see(otp_challenge)
226
- def sms_otp(destination, data = nil)
227
- # TODO: validate that 'destination' is a phone number
228
- otp_challenge('sms-otp-6', destination, nil, data)
229
- end
230
-
231
- # push to a user's device based off of their id, email, or username
232
- # @raise ArgumentError when not enough information is provided to find the user
233
- # @param [String] session_id optional a valid Tozny session_id
234
- # @param [String] user_id optional a Tozny user_id
235
- # @param [String] email optional an email for a field associated with tozny_email
236
- # @param [String] username optional a username for a field associated with tozny_username
237
- # @return [Hash {Symbol => String, Integer, Array<TrueClass, FalseClass>}] the result of the push attempt. Will be true if any device owned by the user is successfully sent a push.
238
- def user_push(session_id = nil, user_id = nil, email = nil, username = nil)
239
- raise ArgumentError, 'must provide either a Tozny user id, a tozny_email, or a tozny_username in order to find the user to push to' if
240
- user_id.nil? && email.nil? && username.nil?
241
- session_id ||= user_api.login_challenge[:session_id]
242
- request_obj = {
243
- method: 'realm.user_push',
244
- session_id: session_id
245
- }
246
- if !user_id.nil?
247
- request_obj[:user_id] = user_id
248
- elsif !email.nil?
249
- request_obj[:tozny_email] = email
250
- elsif !username.nil?
251
- request_obj[:tozny_username] = username
252
- end
253
- raw_call request_obj
254
- end
255
-
256
- # perform a raw(ish) API call
257
- # @param [Hash{Symbol, String => Object}] request_obj The request to conduct. Should include a :method at the least. Prefer symbol keys to string keys
258
- # @return [Object] The parsed result of the request. NOTE: most types will be stringified for most requests
259
- def raw_call(request_obj)
260
- request_obj[:nonce] = Tozny::Core.generate_nonce # generate the nonce
261
- request_obj[:expires_at] = Time.now.to_i + 5 * 60 # UNIX timestamp for now +5 min TODO: does this work with check_login_via_api, or should it default to a passed in expires_at?
262
- unless request_obj.key?('realm_key_id') || request_obj.key?(:realm_key_id) # check for both string and symbol
263
- # TODO: how should we handle conflicts of symbol and string keys?
264
- request_obj[:realm_key_id] = realm_key_id
265
- end
266
- encoded_params = Tozny::Core.encode_and_sign(request_obj.to_json, realm_secret) # make a proper request of it.
267
- request_url = api_url # copy the URL to a local variable so that we can add the query params
268
- request_url.query = URI.encode_www_form encoded_params # encode signed_data and signature as query params
269
- # p request_url
270
- http_result = Net::HTTP.get(request_url)
271
- JSON.parse(http_result, symbolize_names: true) # TODO: handle errors
272
- end
273
- end
274
- end
1
+ require 'tozny/user'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ # rubocop:disable Metrics/PerceivedComplexity
7
+
8
+ module Tozny
9
+ class Realm
10
+ attr_accessor :realm_key_id, :realm_secret, :api_url, :user_api
11
+
12
+ def initialize(realm_key_id, realm_secret, api_url = nil)
13
+ # set the API URL
14
+ if !api_url.nil?
15
+ self.api_url = api_url
16
+ elsif !(ENV['API_URL'].nil?)
17
+ self.api_url = ENV['API_URL']
18
+ else
19
+ self.api_url = 'https://api.tozny.com/index.php'
20
+ end
21
+ unless self.api_url.is_a? URI # don't try to parse a URI instance into a URI, as this will break
22
+ self.api_url = URI.parse(self.api_url)
23
+ end
24
+
25
+ set_new_realm(realm_key_id, realm_secret)
26
+
27
+ end
28
+
29
+ # use a new realm_key_id and realm_secret. updates the user_api handle to reflect this change as well.
30
+ # @param [String] realm_key_id
31
+ # @param [String] realm_secret
32
+ # @return [TrueClass] will always return true
33
+ def set_new_realm(realm_key_id, realm_secret)
34
+ self.realm_key_id = realm_key_id
35
+ self.realm_secret = realm_secret
36
+ if user_api.is_a? ::Tozny::User
37
+ user_api.set_new_realm(realm_key_id)
38
+ else
39
+ self.user_api = ::Tozny::User.new(realm_key_id, api_url)
40
+ end
41
+ true
42
+ end
43
+
44
+ # verify a login and extract user information from a signed packet forwarded to the server
45
+ # @param [String] signed_data the base64URL data to validate
46
+ # @param [String] signature the string representation of the signature to check the login with
47
+ # @return [Hash, FalseClass] the login information or false if the login did not check out
48
+ def check_login_locally(signed_data, signature)
49
+ if check_signature(signed_data, signature)
50
+ login_info = JSON.parse(::Tozny::Core.base64url_decode(signed_data))
51
+ return false if login_info[:expires_at] < Time.now.to_i
52
+ login_info
53
+ else
54
+ false
55
+ end
56
+ end
57
+
58
+ # verify a login from a user and session id. Does not return complete login information.
59
+ # @param [String] user_id the user_id of the login to check
60
+ # @param [String] session_id the session_id of the login to check
61
+ # @return [Hash] the return from the API
62
+ def check_login_via_api(user_id, session_id) # NOTE: this only returns true/false. You need to parse the data locally. See Tozny::Core.base64url_decode
63
+ raw_call(
64
+ method: 'realm.check_valid_login',
65
+ user_id: user_id,
66
+ session_id: session_id
67
+ )[:return] == 'true'
68
+ end
69
+
70
+ # Add a user to a closed realm
71
+ # @param [String] defer 'true' or 'false', defines whether the user should be deferred to later be completed by the app
72
+ # @param [Hash] meta any metadata to be added to the user (eg, favorite color or mother's home address). All meta will be stored as strings
73
+ # @param [String, OpenSSL::PKey::RSA] pub_key the public key of the user to be added. Only necessary
74
+ # @return [Hash, FalseClass] the user in its current (incomplete if defer is 'true' state)
75
+ # @raise ArgumentError if there is no pubkey when there should be one
76
+ def user_add(defer = 'false', meta = nil, pub_key)
77
+ unless pub_key.nil?
78
+ pub_key = OpenSSL::PKey::RSA.new pub_key if pub_key.is_a? String
79
+ pub_key = pub_key.public_key if pub_key.private?
80
+ end
81
+
82
+ request_obj = {
83
+ method: 'realm.user_add',
84
+ defer: defer
85
+ }
86
+ if defer == 'false'
87
+ raise ArgumentError, 'Must provide a public key if not using deferred enrollment' if pub_key.nil?
88
+ request_obj[:pub_key] = pub_key
89
+ end
90
+
91
+ unless meta.nil?
92
+ request_obj[:extra_fields] = Tozny::Core.base64url_encode(meta.to_json)
93
+ end
94
+
95
+ user = raw_call request_obj
96
+ return false unless user[:return] == 'ok'
97
+ user
98
+ end
99
+
100
+ # update a user's meta fields
101
+ # * Note: all meta fields are stored as strings
102
+ # @param [String] user_id
103
+ # @param [Hash{Symbol,String=>Object}] meta the metadata fields to update, along with their new values
104
+ # @return [Hash] the updated user
105
+ def user_update(user_id, meta)
106
+ raw_call(
107
+ method: 'realm.user_update',
108
+ user_id: user_id,
109
+ extra_fields: Tozny::Core.base64url_encode(meta.to_json)
110
+ )
111
+ end
112
+
113
+ # @param [String] user_id
114
+ # @return [Hash] the result of the request to the API
115
+ def user_delete(user_id)
116
+ raw_call(
117
+ method: 'realm.user_delete',
118
+ user_id: user_id
119
+ )
120
+ end
121
+
122
+ # retrieve a user's information
123
+ # * Note: all meta fields are stored as strings
124
+ # @param [String] user_id the id or email (if is_id = false) of the user to get
125
+ # @param [TrueClass, FalseClass] is_id true if looking up the user by id, false if looking up by email. defaults to true
126
+ # @return [Hash] the user's information
127
+ # @raise ArgumentError on failed lookup
128
+ def user_get(user_id, is_id = true)
129
+ request_obj = {
130
+ method: 'realm.user_get'
131
+ }
132
+ if is_id
133
+ request_obj[:user_id] = user_id
134
+ else
135
+ request_obj[:tozny_email] = user_id
136
+ end
137
+
138
+ user = raw_call(request_obj)
139
+ if user.nil? || user[:results].nil?
140
+ raise ArgumentError, ('No user was found for ' + (is_id ? 'id' : 'email') + ': ' + user_id + '.')
141
+ end
142
+ user[:results]
143
+ end
144
+
145
+ # performs a device add call
146
+ # @param [String] user_id
147
+ # @return [Hash] the result of the call: keys include :user_id, :temp_key, :secret_enrollment_url, and :key_id
148
+ def user_device_add(user_id)
149
+ raw_call(
150
+ method: 'realm.user_device_add',
151
+ user_id: user_id
152
+ )
153
+ end
154
+
155
+ # create an OOB challenge question session
156
+ # @param [Hash<Symbol, String>, String] question either a question hash, as specified by the options, or the text of a question to be presented to the user. Required.
157
+ # @option question [String] :question The text of the question to be presented to the user
158
+ # @option question [String] :success The URL the user's browser should be redirected to after successful authentication
159
+ # @option question [String] :error The URL the user's browser should be redirected to after unsuccessful authentication
160
+ # @param [String] success_url The URL the user's browser should be redirected to after successful authentication if not specified in the question object
161
+ # @param [String] error_url The URL the user's browser should be redirected to after unsuccessful authentication if not specified in the question object
162
+ # @param [String] user_id optional. The user who should answer the question.
163
+ # @raise ArgumentError on invalid question type
164
+ # TODO: support URI objects instead of strings for success and error
165
+ # @return [Hash] the result of the API call
166
+ def question_challenge(question, success_url, error_url, user_id)
167
+ raise ArgumentError, 'question must either be a string or an options hash as specified' unless (question.is_a?String) || (question.is_a?Hash)
168
+ final_question = nil # scope final_question and prevent linting errors
169
+ if question.is_a?Hash
170
+ final_question = question
171
+ final_question[:type] = 'callback'
172
+ elsif (success_url.is_a?String) || (error_url.is_a?String)
173
+ final_question = {
174
+ type: 'callback',
175
+ question: question
176
+ }
177
+ final_question[:success] = success_url if success_url.is_a?String
178
+ final_question[:error] = error_url if error_url.is_a?String
179
+ else
180
+ final_question = {
181
+ type: 'question',
182
+ question: question
183
+ }
184
+ end
185
+ request_obj = {
186
+ method: 'realm.question_challenge',
187
+ question: final_question
188
+ }
189
+ request_obj[:user_id] = user_id if user_id.is_a?String
190
+ raw_call request_obj
191
+ end
192
+
193
+ # Create an OTP challenge session
194
+ # @return [Hash] a hash [session_id, presence] containing an OTP session id and an OTP presence (an alias for a type-destination combination)
195
+ # @param [String] type one of 'sms-otp-6', 'sms-otp-8': the type of the OTP to send
196
+ # @param [String] destination the destination for the OTP. For an SMS OTP, this should be a phone number
197
+ # @param [String] presence can be used instead of 'type' and 'destination': an OTP presence provided by the TOZNY API
198
+ # @param [Object] data optional passthru data to be added to the signed response on a successful request
199
+ # @param [String] context One of "verify," "authenticate," or "enroll"
200
+ # @raise ArgumentError when not enough information to submit an OTP request
201
+ # @raise ArgumentError on invalid request type
202
+ def otp_challenge(type = nil, destination = nil, presence = nil, data = nil, context = nil)
203
+ raise ArgumentError, 'must provide either a presence or a type and destination' if (type.nil? || destination.nil?) && presence.nil?
204
+ request_obj = {
205
+ method: 'realm.otp_challenge'
206
+ }
207
+ unless data.nil?
208
+ data = data.to_json unless data.is_a?String
209
+ request_obj[:data] = data
210
+ end
211
+
212
+ if presence.nil?
213
+ raise ArgumentError, "request type must one of 'sms-otp-6' or 'sms-otp-8'" unless %w(sms-otp-6 sms-otp-8).include? type
214
+ request_obj[:type] = type
215
+ # TODO: consider validating that 'destination' is a valid phone number when 'type' is sms-otp-*
216
+ request_obj[:destination] = destination
217
+ else
218
+ request_obj[:presence] = presence
219
+ end
220
+
221
+ request_obj[:context] = context unless context.nil?
222
+
223
+ raw_call request_obj
224
+ end
225
+
226
+ # Create a magic link challenge session
227
+ #
228
+ # @param [String] destination The email address or phone number to which we will send a challenge
229
+ # @param [String] endpoint URL endpoint to use as a base for the challenge URL
230
+ # @param [Integer] lifespan Number of seconds for which the challenge URL is valid
231
+ # @param [String] context One of "verify," "authenticate," or "enroll"
232
+ # @param [Bool] send Flag whether or not to send the email (if false will return the OTP URL instead)
233
+ # @param [String] data JSON-encoded string of data to be signed along with the request
234
+ #
235
+ # @return [Hash] a hash of the session and presence for the challenge
236
+ #
237
+ def link_challenge(destination, endpoint, lifespan = nil, context = nil, send = true, data = nil)
238
+ request_obj = {
239
+ method: 'realm.link_challenge',
240
+ destination: destination,
241
+ endpoint: endpoint,
242
+ send: !! send ? 'yes' : 'no' # Hacky double-bang to convert nil to false and anything else into a boolean
243
+ }
244
+ request_obj['lifespan'] = lifespan unless lifespan.nil?
245
+ request_obj['context'] = context unless context.nil?
246
+ request_obj['data'] = data unless data.nil?
247
+
248
+ raw_call request_obj
249
+ end
250
+
251
+ # Shorthand method to send an 6-digit OTP via SMS without a presence token
252
+ # @param destination @see(otp_challenge)
253
+ # @param data @see(otp_challenge)
254
+ # @return @see(otp_challenge)
255
+ def sms_otp(destination, data = nil)
256
+ # TODO: validate that 'destination' is a phone number
257
+ otp_challenge('sms-otp-6', destination, nil, data)
258
+ end
259
+
260
+ # push to a user's device based off of their id, email, or username
261
+ # @raise ArgumentError when not enough information is provided to find the user
262
+ # @param [String] session_id optional a valid Tozny session_id
263
+ # @param [String] user_id optional a Tozny user_id
264
+ # @param [String] email optional an email for a field associated with tozny_email
265
+ # @param [String] username optional a username for a field associated with tozny_username
266
+ # @return [Hash {Symbol => String, Integer, Array<TrueClass, FalseClass>}] the result of the push attempt. Will be true if any device owned by the user is successfully sent a push.
267
+ def user_push(session_id = nil, user_id = nil, email = nil, username = nil)
268
+ raise ArgumentError, 'must provide either a Tozny user id, a tozny_email, or a tozny_username in order to find the user to push to' if
269
+ user_id.nil? && email.nil? && username.nil?
270
+ session_id ||= user_api.login_challenge[:session_id]
271
+ request_obj = {
272
+ method: 'realm.user_push',
273
+ session_id: session_id
274
+ }
275
+ if !user_id.nil?
276
+ request_obj[:user_id] = user_id
277
+ elsif !email.nil?
278
+ request_obj[:tozny_email] = email
279
+ elsif !username.nil?
280
+ request_obj[:tozny_username] = username
281
+ end
282
+ raw_call request_obj
283
+ end
284
+
285
+ # perform a raw(ish) API call
286
+ # @param [Hash{Symbol, String => Object}] request_obj The request to conduct. Should include a :method at the least. Prefer symbol keys to string keys
287
+ # @return [Object] The parsed result of the request. NOTE: most types will be stringified for most requests
288
+ def raw_call(request_obj)
289
+ request_obj[:nonce] = Tozny::Core.generate_nonce # generate the nonce
290
+ request_obj[:expires_at] = Time.now.to_i + 5 * 60 # UNIX timestamp for now +5 min TODO: does this work with check_login_via_api, or should it default to a passed in expires_at?
291
+ unless request_obj.key?('realm_key_id') || request_obj.key?(:realm_key_id) # check for both string and symbol
292
+ # TODO: how should we handle conflicts of symbol and string keys?
293
+ request_obj[:realm_key_id] = realm_key_id
294
+ end
295
+ encoded_params = Tozny::Core.encode_and_sign(request_obj.to_json, realm_secret) # make a proper request of it.
296
+ request_url = api_url # copy the URL to a local variable so that we can add the query params
297
+ request_url.query = URI.encode_www_form encoded_params # encode signed_data and signature as query params
298
+ # p request_url
299
+ http_result = Net::HTTP.get(request_url)
300
+ JSON.parse(http_result, symbolize_names: true) # TODO: handle errors
301
+ end
302
+ end
303
+ end
@@ -1,93 +1,153 @@
1
- require 'tozny/auth/common'
2
-
3
- module Tozny
4
- class User
5
- attr_accessor :realm_key_id, :api_url
6
- def initialize(realm_key_id, api_url = nil)
7
- if !api_url.nil?
8
- self.api_url = api_url
9
- elsif !(ENV['API_URL'].nil?) # rubocop:disable all
10
- self.api_url = ENV['API_URL']
11
- else
12
- self.api_url = 'https://api.tozny.com/index.php'
13
- end
14
-
15
- self.api_url = URI.parse(self.api_url) unless self.api_url.is_a? URI
16
-
17
- set_new_realm(realm_key_id)
18
- end
19
-
20
- # check the status of a session
21
- # @param [String] session_id the session to check
22
- # @return [TrueClass, FalseClass] 'true' if the use has logged in with the session, false otherwise
23
- def check_session_status(session_id)
24
- raw_call(
25
- method: 'user.check_session_status',
26
- session_id: session_id
27
- ).key?(:signed_data)
28
- end
29
-
30
- # use a new realm_key_id
31
- # @param [String] realm_key_id
32
- # @return [TrueClass] will always return true
33
- def set_new_realm(realm_key_id) # rubocop:disable Style/AccessorMethodName
34
- self.realm_key_id = realm_key_id
35
- true
36
- end
37
-
38
- # Generate a new login challenge session
39
- # @param [TrueClass, FalseClass] user_add optional: whether or not to create an enrollment challenge rather than an authentication challenge
40
- # @return [Hash] a challenge session (:challenge, :realm_key_id, :session_id, :qr_url, :mobile_url, :created_at, :presence = "")
41
- def login_challenge(user_add = nil)
42
- request_obj = {
43
- method: 'user.login_challenge'
44
- }
45
- request_obj[:user_add] = user_add if user_add
46
- raw_call request_obj
47
- end
48
-
49
- # Create an OTP challenge session
50
- # @return [Hash] a hash [session_id, presence] containing an OTP session id and an OTP presence (an alias for a type-destination combination)
51
- # @param [String] type one of 'sms-otp-6', 'sms-otp-8': the type of the OTP to send
52
- # @param [String] destination the destination for the OTP. For an SMS OTP, this should be a phone number
53
- # @param [String] presence can be used instead of 'type' and 'destination': an OTP presence provided by the TOZNY API
54
- # @raise ArgumentError when not enough information to submit an OTP request
55
- # @raise ArgumentError on invalid request type
56
- def otp_challenge(type = nil, destination = nil, presence = nil)
57
- raise ArgumentError, 'must provide either a presence or a type and destination' if (type.nil? || destination.nil?) && presence.nil?
58
- request_obj = {
59
- method: 'user.otp_challenge'
60
- }
61
- if presence.nil?
62
- raise ArgumentError, "request type must one of 'sms-otp-6' or 'sms-otp-8'" unless %w(sms-otp-6 sms-otp-8).include? type
63
- request_obj[:type] = type
64
- # TODO: consider validating that 'destination' is a valid phone number when 'type' is sms-otp-*
65
- request_obj[:destination] = destination
66
- else
67
- request_obj[:presence] = presence
68
- end
69
- raw_call request_obj
70
- end
71
-
72
- # Check an OTP against an OTP session
73
- # @param [String] session_id the OTP session to validate
74
- # @param [String, Integer] otp the OTP to check
75
- # @return [Hash] The signed_data and signature containing a session ID and metadata, if any, on success. Otherwise, error[s].
76
- def otp_result(session_id, otp)
77
- raw_call(method: 'user.otp_result', session_id: session_id, otp: otp)
78
- end
79
-
80
- # Perform a raw(ish) API call
81
- # @param [Hash{Symbol, String => Object}] request_obj The request to conduct. Should include a :method at the least. Prefer symbol keys to string keys
82
- # @return [Object] The parsed result of the request. NOTE: most types will be stringified for most requests
83
- def raw_call(request_obj)
84
- unless request_obj.key?('realm_key_id') || request_obj.key?(:realm_key_id) # check for both string and symbol
85
- # TODO: how should we handle conflicts of symbol and string keys?
86
- request_obj[:realm_key_id] = realm_key_id
87
- end
88
- request_url = api_url # copy the URL to a local variable so that we can add the query params
89
- request_url.query = URI.encode_www_form request_obj # encode request as query params
90
- JSON.parse(Net::HTTP.get(request_url), symbolize_names: true)
91
- end
92
- end
93
- end
1
+ require 'tozny/auth/common'
2
+
3
+ module Tozny
4
+ class User
5
+ attr_accessor :realm_key_id, :api_url
6
+ def initialize(realm_key_id, api_url = nil)
7
+ if !api_url.nil?
8
+ self.api_url = api_url
9
+ elsif !(ENV['API_URL'].nil?) # rubocop:disable all
10
+ self.api_url = ENV['API_URL']
11
+ else
12
+ self.api_url = 'https://api.tozny.com/index.php'
13
+ end
14
+
15
+ self.api_url = URI.parse(self.api_url) unless self.api_url.is_a? URI
16
+
17
+ set_new_realm(realm_key_id)
18
+ end
19
+
20
+ # check the status of a session
21
+ # @param [String] session_id the session to check
22
+ # @return [TrueClass, FalseClass] 'true' if the use has logged in with the session, false otherwise
23
+ def check_session_status(session_id)
24
+ raw_call(
25
+ method: 'user.check_session_status',
26
+ session_id: session_id
27
+ ).key?(:signed_data)
28
+ end
29
+
30
+ # use a new realm_key_id
31
+ # @param [String] realm_key_id
32
+ # @return [TrueClass] will always return true
33
+ def set_new_realm(realm_key_id) # rubocop:disable Style/AccessorMethodName
34
+ self.realm_key_id = realm_key_id
35
+ true
36
+ end
37
+
38
+ # Generate a new login challenge session
39
+ # @param [TrueClass, FalseClass] user_add optional: whether or not to create an enrollment challenge rather than an authentication challenge
40
+ # @return [Hash] a challenge session (:challenge, :realm_key_id, :session_id, :qr_url, :mobile_url, :created_at, :presence = "")
41
+ def login_challenge(user_add = nil)
42
+ request_obj = {
43
+ method: 'user.login_challenge'
44
+ }
45
+ request_obj[:user_add] = user_add if user_add
46
+ raw_call request_obj
47
+ end
48
+
49
+ # Create an OTP challenge session
50
+ # @return [Hash] a hash [session_id, presence] containing an OTP session id and an OTP presence (an alias for a type-destination combination)
51
+ # @param [String] type one of 'sms-otp-6', 'sms-otp-8': the type of the OTP to send
52
+ # @param [String] destination the destination for the OTP. For an SMS OTP, this should be a phone number
53
+ # @param [String] presence can be used instead of 'type' and 'destination': an OTP presence provided by the TOZNY API
54
+ # @param [String] context One of "verify," "authenticate," or "enroll"
55
+ # @raise ArgumentError when not enough information to submit an OTP request
56
+ # @raise ArgumentError on invalid request type
57
+ def otp_challenge(type = nil, destination = nil, presence = nil, context = nil)
58
+ raise ArgumentError, 'must provide either a presence or a type and destination' if (type.nil? || destination.nil?) && presence.nil?
59
+ request_obj = {
60
+ method: 'user.otp_challenge'
61
+ }
62
+ if presence.nil?
63
+ raise ArgumentError, "request type must one of 'sms-otp-6' or 'sms-otp-8'" unless %w(sms-otp-6 sms-otp-8).include? type
64
+ request_obj[:type] = type
65
+ # TODO: consider validating that 'destination' is a valid phone number when 'type' is sms-otp-*
66
+ request_obj[:destination] = destination
67
+ else
68
+ request_obj[:presence] = presence
69
+ end
70
+
71
+ request_obj[:context] = context unless context.nil?
72
+
73
+ raw_call request_obj
74
+ end
75
+
76
+ # Create a magic link challenge session
77
+ #
78
+ # @param [String] destination The email address or phone number to which we will send a challenge
79
+ # @param [String] endpoint URL endpoint to use as a base for the challenge URL
80
+ # @param [String] context One of "verify," "authenticate," or "enroll"
81
+ #
82
+ # @return [Hash] a hash of the session and presence for the challenge
83
+ #
84
+ def link_challenge(destination, endpoint, context = nil)
85
+ request_obj = {
86
+ method: 'user.link_challenge',
87
+ destination: destination,
88
+ endpoint: endpoint
89
+ }
90
+ request_obj['context'] = context unless context.nil?
91
+
92
+ raw_call request_obj
93
+ end
94
+
95
+ # Check an OTP against an OTP session
96
+ # @param [String] session_id the OTP session to validate
97
+ # @param [String, Integer] otp the OTP to check
98
+ # @return [Hash] The signed_data and signature containing a session ID and metadata, if any, on success. Otherwise, error[s].
99
+ def otp_result(session_id, otp)
100
+ raw_call(method: 'user.otp_result', session_id: session_id, otp: otp)
101
+ end
102
+
103
+ # Verify a magic link OTP
104
+ #
105
+ # @param [String] otp The OTP (usually embedded in a magic link)to validate
106
+ #
107
+ # @return [Hash]
108
+ #
109
+ def link_result(otp)
110
+ params = {
111
+ method: 'user.link_result',
112
+ realm_key_id: realm_key_id,
113
+ otp: otp
114
+ }
115
+ raw_call(params)
116
+ end
117
+
118
+ # Exchange a signed OTP challenge (from user.link_result or user.otp_result) for either an
119
+ # enrollment challenge (if the original context was "enroll") or a signed user payload (if
120
+ # the original context was "authenticate").
121
+ #
122
+ # @param [String] signed_data Signed data payload
123
+ # @param [String] signature Realm-signed signature of the payload
124
+ # @param [String] session_id If this was an authentication challenge, provide the session ID to close the session
125
+ #
126
+ # @return [Hash]
127
+ #
128
+ def challenge_exchange(signed_data, signature, session_id = nil)
129
+ params = {
130
+ method: 'user.challenge_exchange',
131
+ realm_key_id: realm_key_id,
132
+ signed_data: signed_data,
133
+ signature: signature
134
+ }
135
+ params['session_id'] = session_id unless session_id.nil?
136
+
137
+ raw_call(params)
138
+ end
139
+
140
+ # Perform a raw(ish) API call
141
+ # @param [Hash{Symbol, String => Object}] request_obj The request to conduct. Should include a :method at the least. Prefer symbol keys to string keys
142
+ # @return [Object] The parsed result of the request. NOTE: most types will be stringified for most requests
143
+ def raw_call(request_obj)
144
+ unless request_obj.key?('realm_key_id') || request_obj.key?(:realm_key_id) # check for both string and symbol
145
+ # TODO: how should we handle conflicts of symbol and string keys?
146
+ request_obj[:realm_key_id] = realm_key_id
147
+ end
148
+ request_url = api_url # copy the URL to a local variable so that we can add the query params
149
+ request_url.query = URI.encode_www_form request_obj # encode request as query params
150
+ JSON.parse(Net::HTTP.get(request_url), symbolize_names: true)
151
+ end
152
+ end
153
+ end
metadata CHANGED
@@ -1,69 +1,69 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tozny-auth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Bell / emanb29
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-06-28 00:00:00.000000000 Z
11
+ date: 2016-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ~>
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.12'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ~>
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.12'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ~>
32
32
  - !ruby/object:Gem::Version
33
33
  version: '10.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ~>
39
39
  - !ruby/object:Gem::Version
40
40
  version: '10.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: minitest
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ~>
46
46
  - !ruby/object:Gem::Version
47
47
  version: '5.0'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ~>
53
53
  - !ruby/object:Gem::Version
54
54
  version: '5.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rubocop
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ~>
60
60
  - !ruby/object:Gem::Version
61
61
  version: 0.41.1
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ~>
67
67
  - !ruby/object:Gem::Version
68
68
  version: 0.41.1
69
69
  description: A set of methods to more conveniently access the Tozny authentication
@@ -74,9 +74,9 @@ executables: []
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
- - ".gitignore"
78
- - ".rubocop.yml"
79
- - ".travis.yml"
77
+ - .gitignore
78
+ - .rubocop.yml
79
+ - .travis.yml
80
80
  - Gemfile
81
81
  - LICENSE
82
82
  - README.md
@@ -100,17 +100,17 @@ require_paths:
100
100
  - lib
101
101
  required_ruby_version: !ruby/object:Gem::Requirement
102
102
  requirements:
103
- - - "~>"
103
+ - - ~>
104
104
  - !ruby/object:Gem::Version
105
105
  version: '2'
106
106
  required_rubygems_version: !ruby/object:Gem::Requirement
107
107
  requirements:
108
- - - ">="
108
+ - - '>='
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  requirements: []
112
112
  rubyforge_project:
113
- rubygems_version: 2.4.5.1
113
+ rubygems_version: 2.6.6
114
114
  signing_key:
115
115
  specification_version: 4
116
116
  summary: Tozny Ruby SDK