ruby-openid2 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +136 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/CONTRIBUTING.md +54 -0
  6. data/LICENSE.txt +210 -0
  7. data/README.md +81 -0
  8. data/SECURITY.md +15 -0
  9. data/lib/hmac/hmac.rb +110 -0
  10. data/lib/hmac/sha1.rb +11 -0
  11. data/lib/hmac/sha2.rb +25 -0
  12. data/lib/openid/association.rb +246 -0
  13. data/lib/openid/consumer/associationmanager.rb +354 -0
  14. data/lib/openid/consumer/checkid_request.rb +179 -0
  15. data/lib/openid/consumer/discovery.rb +516 -0
  16. data/lib/openid/consumer/discovery_manager.rb +144 -0
  17. data/lib/openid/consumer/html_parse.rb +142 -0
  18. data/lib/openid/consumer/idres.rb +513 -0
  19. data/lib/openid/consumer/responses.rb +147 -0
  20. data/lib/openid/consumer/session.rb +36 -0
  21. data/lib/openid/consumer.rb +406 -0
  22. data/lib/openid/cryptutil.rb +112 -0
  23. data/lib/openid/dh.rb +84 -0
  24. data/lib/openid/extension.rb +38 -0
  25. data/lib/openid/extensions/ax.rb +552 -0
  26. data/lib/openid/extensions/oauth.rb +88 -0
  27. data/lib/openid/extensions/pape.rb +170 -0
  28. data/lib/openid/extensions/sreg.rb +268 -0
  29. data/lib/openid/extensions/ui.rb +49 -0
  30. data/lib/openid/fetchers.rb +277 -0
  31. data/lib/openid/kvform.rb +113 -0
  32. data/lib/openid/kvpost.rb +62 -0
  33. data/lib/openid/message.rb +555 -0
  34. data/lib/openid/protocolerror.rb +7 -0
  35. data/lib/openid/server.rb +1571 -0
  36. data/lib/openid/store/filesystem.rb +260 -0
  37. data/lib/openid/store/interface.rb +73 -0
  38. data/lib/openid/store/memcache.rb +109 -0
  39. data/lib/openid/store/memory.rb +79 -0
  40. data/lib/openid/store/nonce.rb +72 -0
  41. data/lib/openid/trustroot.rb +597 -0
  42. data/lib/openid/urinorm.rb +72 -0
  43. data/lib/openid/util.rb +119 -0
  44. data/lib/openid/version.rb +5 -0
  45. data/lib/openid/yadis/accept.rb +141 -0
  46. data/lib/openid/yadis/constants.rb +16 -0
  47. data/lib/openid/yadis/discovery.rb +151 -0
  48. data/lib/openid/yadis/filters.rb +192 -0
  49. data/lib/openid/yadis/htmltokenizer.rb +290 -0
  50. data/lib/openid/yadis/parsehtml.rb +50 -0
  51. data/lib/openid/yadis/services.rb +44 -0
  52. data/lib/openid/yadis/xrds.rb +160 -0
  53. data/lib/openid/yadis/xri.rb +86 -0
  54. data/lib/openid/yadis/xrires.rb +87 -0
  55. data/lib/openid.rb +27 -0
  56. data/lib/ruby-openid.rb +1 -0
  57. data.tar.gz.sig +0 -0
  58. metadata +331 -0
  59. metadata.gz.sig +0 -0
@@ -0,0 +1,354 @@
1
+ require_relative "../dh"
2
+ require_relative "../util"
3
+ require_relative "../kvpost"
4
+ require_relative "../cryptutil"
5
+ require_relative "../protocolerror"
6
+ require_relative "../association"
7
+
8
+ module OpenID
9
+ class Consumer
10
+ # A superclass for implementing Diffie-Hellman association sessions.
11
+ class DiffieHellmanSession
12
+ class << self
13
+ attr_reader :session_type,
14
+ :secret_size,
15
+ :allowed_assoc_types,
16
+ :hashfunc
17
+ end
18
+
19
+ def initialize(dh = nil)
20
+ dh = DiffieHellman.from_defaults if dh.nil?
21
+ @dh = dh
22
+ end
23
+
24
+ # Return the query parameters for requesting an association
25
+ # using this Diffie-Hellman association session
26
+ def get_request
27
+ args = {"dh_consumer_public" => CryptUtil.num_to_base64(@dh.public)}
28
+ unless @dh.using_default_values?
29
+ args["dh_modulus"] = CryptUtil.num_to_base64(@dh.modulus)
30
+ args["dh_gen"] = CryptUtil.num_to_base64(@dh.generator)
31
+ end
32
+
33
+ args
34
+ end
35
+
36
+ # Process the response from a successful association request and
37
+ # return the shared secret for this association
38
+ def extract_secret(response)
39
+ dh_server_public64 = response.get_arg(
40
+ OPENID_NS,
41
+ "dh_server_public",
42
+ NO_DEFAULT,
43
+ )
44
+ enc_mac_key64 = response.get_arg(OPENID_NS, "enc_mac_key", NO_DEFAULT)
45
+ dh_server_public = CryptUtil.base64_to_num(dh_server_public64)
46
+ enc_mac_key = Util.from_base64(enc_mac_key64)
47
+ @dh.xor_secret(
48
+ self.class.hashfunc,
49
+ dh_server_public,
50
+ enc_mac_key,
51
+ )
52
+ end
53
+ end
54
+
55
+ # A Diffie-Hellman association session that uses SHA1 as its hash
56
+ # function
57
+ class DiffieHellmanSHA1Session < DiffieHellmanSession
58
+ @session_type = "DH-SHA1"
59
+ @secret_size = 20
60
+ @allowed_assoc_types = ["HMAC-SHA1"]
61
+ @hashfunc = CryptUtil.method(:sha1)
62
+ end
63
+
64
+ # A Diffie-Hellman association session that uses SHA256 as its hash
65
+ # function
66
+ class DiffieHellmanSHA256Session < DiffieHellmanSession
67
+ @session_type = "DH-SHA256"
68
+ @secret_size = 32
69
+ @allowed_assoc_types = ["HMAC-SHA256"]
70
+ @hashfunc = CryptUtil.method(:sha256)
71
+ end
72
+
73
+ # An association session that does not use encryption
74
+ class NoEncryptionSession
75
+ class << self
76
+ attr_reader :session_type, :allowed_assoc_types
77
+ end
78
+ @session_type = "no-encryption"
79
+ @allowed_assoc_types = %w[HMAC-SHA1 HMAC-SHA256]
80
+
81
+ def get_request
82
+ {}
83
+ end
84
+
85
+ def extract_secret(response)
86
+ mac_key64 = response.get_arg(OPENID_NS, "mac_key", NO_DEFAULT)
87
+ Util.from_base64(mac_key64)
88
+ end
89
+ end
90
+
91
+ # An object that manages creating and storing associations for an
92
+ # OpenID provider endpoint
93
+ class AssociationManager
94
+ def self.create_session(session_type)
95
+ case session_type
96
+ when "no-encryption"
97
+ NoEncryptionSession.new
98
+ when "DH-SHA1"
99
+ DiffieHellmanSHA1Session.new
100
+ when "DH-SHA256"
101
+ DiffieHellmanSHA256Session.new
102
+ else
103
+ raise ArgumentError, "Unknown association session type: " \
104
+ "#{session_type.inspect}"
105
+ end
106
+ end
107
+
108
+ def initialize(store, server_url, compatibility_mode = false,
109
+ negotiator = nil)
110
+ @store = store
111
+ @server_url = server_url
112
+ @compatibility_mode = compatibility_mode
113
+ @negotiator = negotiator || DefaultNegotiator
114
+ end
115
+
116
+ def get_association
117
+ return if @store.nil?
118
+
119
+ assoc = @store.get_association(@server_url)
120
+ if assoc.nil? || assoc.expires_in <= 0
121
+ assoc = negotiate_association
122
+ @store.store_association(@server_url, assoc) unless assoc.nil?
123
+ end
124
+
125
+ assoc
126
+ end
127
+
128
+ def negotiate_association
129
+ assoc_type, session_type = @negotiator.get_allowed_type
130
+ begin
131
+ request_association(assoc_type, session_type)
132
+ rescue ServerError => e
133
+ supported_types = extract_supported_association_type(e, assoc_type)
134
+ unless supported_types.nil?
135
+ # Attempt to create an association from the assoc_type and
136
+ # session_type that the server told us it supported.
137
+ assoc_type, session_type = supported_types
138
+ begin
139
+ request_association(assoc_type, session_type)
140
+ rescue ServerError
141
+ Util.log("Server #{@server_url} refused its suggested " \
142
+ "association type: session_type=#{session_type}, " \
143
+ "assoc_type=#{assoc_type}")
144
+ nil
145
+ end
146
+ end
147
+ rescue InvalidOpenIDNamespace
148
+ Util.log("Server #{@server_url} returned a malformed association " \
149
+ "response. Falling back to check_id mode for this request.")
150
+ nil
151
+ end
152
+ end
153
+
154
+ protected
155
+
156
+ def extract_supported_association_type(server_error, assoc_type)
157
+ # Any error message whose code is not 'unsupported-type' should
158
+ # be considered a total failure.
159
+ if server_error.error_code != "unsupported-type" or
160
+ server_error.message.is_openid1
161
+ Util.log("Server error when requesting an association from " \
162
+ "#{@server_url}: #{server_error.error_text}")
163
+ return
164
+ end
165
+
166
+ # The server didn't like the association/session type that we
167
+ # sent, and it sent us back a message that might tell us how to
168
+ # handle it.
169
+ Util.log("Unsupported association type #{assoc_type}: " \
170
+ "#{server_error.error_text}")
171
+
172
+ # Extract the session_type and assoc_type from the error message
173
+ assoc_type = server_error.message.get_arg(OPENID_NS, "assoc_type")
174
+ session_type = server_error.message.get_arg(OPENID_NS, "session_type")
175
+
176
+ if assoc_type.nil? or session_type.nil?
177
+ Util.log("Server #{@server_url} responded with unsupported " \
178
+ "association session but did not supply a fallback.")
179
+ nil
180
+ elsif !@negotiator.allowed?(assoc_type, session_type)
181
+ Util.log("Server sent unsupported session/association type: " \
182
+ "session_type=#{session_type}, assoc_type=#{assoc_type}")
183
+ nil
184
+ else
185
+ [assoc_type, session_type]
186
+ end
187
+ end
188
+
189
+ # Make and process one association request to this endpoint's OP
190
+ # endpoint URL. Returns an association object or nil if the
191
+ # association processing failed. Raises ServerError when the
192
+ # remote OpenID server returns an error.
193
+ def request_association(assoc_type, session_type)
194
+ assoc_session, args = create_associate_request(assoc_type, session_type)
195
+
196
+ begin
197
+ response = OpenID.make_kv_post(args, @server_url)
198
+ extract_association(response, assoc_session)
199
+ rescue HTTPStatusError => e
200
+ Util.log("Got HTTP status error when requesting association: #{e}")
201
+ nil
202
+ rescue Message::KeyNotFound => e
203
+ Util.log("Missing required parameter in response from " \
204
+ "#{@server_url}: #{e}")
205
+ nil
206
+ rescue ProtocolError => e
207
+ Util.log("Protocol error processing response from #{@server_url}: " \
208
+ "#{e}")
209
+ nil
210
+ end
211
+ end
212
+
213
+ # Create an association request for the given assoc_type and
214
+ # session_type. Returns a pair of the association session object
215
+ # and the request message that will be sent to the server.
216
+ def create_associate_request(assoc_type, session_type)
217
+ assoc_session = self.class.create_session(session_type)
218
+ args = {
219
+ "mode" => "associate",
220
+ "assoc_type" => assoc_type,
221
+ }
222
+
223
+ args["ns"] = OPENID2_NS unless @compatibility_mode
224
+
225
+ # Leave out the session type if we're in compatibility mode
226
+ # *and* it's no-encryption.
227
+ if !@compatibility_mode ||
228
+ assoc_session.class.session_type != "no-encryption"
229
+ args["session_type"] = assoc_session.class.session_type
230
+ end
231
+
232
+ args.merge!(assoc_session.get_request)
233
+ message = Message.from_openid_args(args)
234
+ [assoc_session, message]
235
+ end
236
+
237
+ # Given an association response message, extract the OpenID 1.X
238
+ # session type. Returns the association type for this message
239
+ #
240
+ # This function mostly takes care of the 'no-encryption' default
241
+ # behavior in OpenID 1.
242
+ #
243
+ # If the association type is plain-text, this function will
244
+ # return 'no-encryption'
245
+ def get_openid1_session_type(assoc_response)
246
+ # If it's an OpenID 1 message, allow session_type to default
247
+ # to nil (which signifies "no-encryption")
248
+ session_type = assoc_response.get_arg(OPENID_NS, "session_type")
249
+
250
+ # Handle the differences between no-encryption association
251
+ # respones in OpenID 1 and 2:
252
+
253
+ # no-encryption is not really a valid session type for
254
+ # OpenID 1, but we'll accept it anyway, while issuing a
255
+ # warning.
256
+ if session_type == "no-encryption"
257
+ Util.log("WARNING: #{@server_url} sent 'no-encryption'" \
258
+ "for OpenID 1.X")
259
+
260
+ # Missing or empty session type is the way to flag a
261
+ # 'no-encryption' response. Change the session type to
262
+ # 'no-encryption' so that it can be handled in the same
263
+ # way as OpenID 2 'no-encryption' respones.
264
+ elsif session_type == "" || session_type.nil?
265
+ session_type = "no-encryption"
266
+ end
267
+
268
+ session_type
269
+ end
270
+
271
+ def self.extract_expires_in(message)
272
+ # expires_in should be a base-10 string.
273
+ expires_in_str = message.get_arg(OPENID_NS, "expires_in", NO_DEFAULT)
274
+ raise ProtocolError, "Invalid expires_in field: #{expires_in_str}" unless /\A\d+\Z/.match?(expires_in_str)
275
+
276
+ expires_in_str.to_i
277
+ end
278
+
279
+ # Attempt to extract an association from the response, given the
280
+ # association response message and the established association
281
+ # session.
282
+ def extract_association(assoc_response, assoc_session)
283
+ # Extract the common fields from the response, raising an
284
+ # exception if they are not found
285
+ assoc_type = assoc_response.get_arg(
286
+ OPENID_NS,
287
+ "assoc_type",
288
+ NO_DEFAULT,
289
+ )
290
+ assoc_handle = assoc_response.get_arg(
291
+ OPENID_NS,
292
+ "assoc_handle",
293
+ NO_DEFAULT,
294
+ )
295
+ expires_in = self.class.extract_expires_in(assoc_response)
296
+
297
+ # OpenID 1 has funny association session behaviour.
298
+ session_type = if assoc_response.is_openid1
299
+ get_openid1_session_type(assoc_response)
300
+ else
301
+ assoc_response.get_arg(
302
+ OPENID2_NS,
303
+ "session_type",
304
+ NO_DEFAULT,
305
+ )
306
+ end
307
+
308
+ # Session type mismatch
309
+ if assoc_session.class.session_type != session_type
310
+ if assoc_response.is_openid1 and session_type == "no-encryption"
311
+ # In OpenID 1, any association request can result in a
312
+ # 'no-encryption' association response. Setting
313
+ # assoc_session to a new no-encryption session should
314
+ # make the rest of this function work properly for
315
+ # that case.
316
+ assoc_session = NoEncryptionSession.new
317
+ else
318
+ # Any other mismatch, regardless of protocol version
319
+ # results in the failure of the association session
320
+ # altogether.
321
+ raise ProtocolError, "Session type mismatch. Expected " \
322
+ "#{assoc_session.class.session_type}, got " \
323
+ "#{session_type}"
324
+ end
325
+ end
326
+
327
+ # Make sure assoc_type is valid for session_type
328
+ unless assoc_session.class.allowed_assoc_types.member?(assoc_type)
329
+ raise ProtocolError, "Unsupported assoc_type for session " \
330
+ "#{assoc_session.class.session_type} " \
331
+ "returned: #{assoc_type}"
332
+ end
333
+
334
+ # Delegate to the association session to extract the secret
335
+ # from the response, however is appropriate for that session
336
+ # type.
337
+ begin
338
+ secret = assoc_session.extract_secret(assoc_response)
339
+ rescue Message::KeyNotFound, ArgumentError => e
340
+ raise ProtocolError, "Malformed response for " \
341
+ "#{assoc_session.class.session_type} " \
342
+ "session: #{e.message}"
343
+ end
344
+
345
+ Association.from_expires_in(
346
+ expires_in,
347
+ assoc_handle,
348
+ secret,
349
+ assoc_type,
350
+ )
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,179 @@
1
+ require_relative "../message"
2
+ require_relative "../util"
3
+
4
+ module OpenID
5
+ class Consumer
6
+ # An object that holds the state necessary for generating an
7
+ # OpenID authentication request. This object holds the association
8
+ # with the server and the discovered information with which the
9
+ # request will be made.
10
+ #
11
+ # It is separate from the consumer because you may wish to add
12
+ # things to the request before sending it on its way to the
13
+ # server. It also has serialization options that let you encode
14
+ # the authentication request as a URL or as a form POST.
15
+ class CheckIDRequest
16
+ attr_accessor :return_to_args, :message
17
+ attr_reader :endpoint, :anonymous
18
+
19
+ # Users of this library should not create instances of this
20
+ # class. Instances of this class are created by the library
21
+ # when needed.
22
+ def initialize(assoc, endpoint)
23
+ @assoc = assoc
24
+ @endpoint = endpoint
25
+ @return_to_args = {}
26
+ @message = Message.new(endpoint.preferred_namespace)
27
+ @anonymous = false
28
+ end
29
+
30
+ # Set whether this request should be made anonymously. If a
31
+ # request is anonymous, the identifier will not be sent in the
32
+ # request. This is only useful if you are making another kind of
33
+ # request with an extension in this request.
34
+ #
35
+ # Anonymous requests are not allowed when the request is made
36
+ # with OpenID 1.
37
+ def anonymous=(is_anonymous)
38
+ if is_anonymous && @message.is_openid1
39
+ raise ArgumentError, "OpenID1 requests MUST include the " \
40
+ "identifier in the request"
41
+ end
42
+ @anonymous = is_anonymous
43
+ end
44
+
45
+ # Add an object that implements the extension interface for
46
+ # adding arguments to an OpenID message to this checkid request.
47
+ #
48
+ # extension_request: an OpenID::Extension object.
49
+ def add_extension(extension_request)
50
+ extension_request.to_message(@message)
51
+ end
52
+
53
+ # Add an extension argument to this OpenID authentication
54
+ # request. You probably want to use add_extension and the
55
+ # OpenID::Extension interface.
56
+ #
57
+ # Use caution when adding arguments, because they will be
58
+ # URL-escaped and appended to the redirect URL, which can easily
59
+ # get quite long.
60
+ def add_extension_arg(namespace, key, value)
61
+ @message.set_arg(namespace, key, value)
62
+ end
63
+
64
+ # Produce a OpenID::Message representing this request.
65
+ #
66
+ # Not specifying a return_to URL means that the user will not be
67
+ # returned to the site issuing the request upon its completion.
68
+ #
69
+ # If immediate mode is requested, the OpenID provider is to send
70
+ # back a response immediately, useful for behind-the-scenes
71
+ # authentication attempts. Otherwise the OpenID provider may
72
+ # engage the user before providing a response. This is the
73
+ # default case, as the user may need to provide credentials or
74
+ # approve the request before a positive response can be sent.
75
+ def get_message(realm, return_to = nil, immediate = false)
76
+ if !return_to.nil?
77
+ return_to = Util.append_args(return_to, @return_to_args)
78
+ elsif immediate
79
+ raise ArgumentError, '"return_to" is mandatory when using ' \
80
+ '"checkid_immediate"'
81
+ elsif @message.is_openid1
82
+ raise ArgumentError, '"return_to" is mandatory for OpenID 1 ' \
83
+ "requests"
84
+ elsif @return_to_args.empty?
85
+ raise ArgumentError, 'extra "return_to" arguments were specified, ' \
86
+ "but no return_to was specified"
87
+ end
88
+
89
+ message = @message.copy
90
+
91
+ mode = immediate ? "checkid_immediate" : "checkid_setup"
92
+ message.set_arg(OPENID_NS, "mode", mode)
93
+
94
+ realm_key = message.is_openid1 ? "trust_root" : "realm"
95
+ message.set_arg(OPENID_NS, realm_key, realm)
96
+
97
+ message.set_arg(OPENID_NS, "return_to", return_to) unless return_to.nil?
98
+
99
+ unless @anonymous
100
+ if @endpoint.is_op_identifier
101
+ # This will never happen when we're in OpenID 1
102
+ # compatibility mode, as long as is_op_identifier()
103
+ # returns false whenever preferred_namespace returns
104
+ # OPENID1_NS.
105
+ claimed_id = request_identity = IDENTIFIER_SELECT
106
+ else
107
+ request_identity = @endpoint.get_local_id
108
+ claimed_id = @endpoint.claimed_id
109
+ end
110
+
111
+ # This is true for both OpenID 1 and 2
112
+ message.set_arg(OPENID_NS, "identity", request_identity)
113
+
114
+ message.set_arg(OPENID2_NS, "claimed_id", claimed_id) if message.is_openid2
115
+ end
116
+
117
+ if @assoc && (message.is_openid1 || !%w[checkid_setup checkid_immediate].include?(mode))
118
+ message.set_arg(OPENID_NS, "assoc_handle", @assoc.handle)
119
+ assoc_log_msg = "with assocication #{@assoc.handle}"
120
+ else
121
+ assoc_log_msg = "using stateless mode."
122
+ end
123
+
124
+ Util.log("Generated #{mode} request to #{@endpoint.server_url} " \
125
+ "#{assoc_log_msg}")
126
+ message
127
+ end
128
+
129
+ # Returns a URL with an encoded OpenID request.
130
+ #
131
+ # The resulting URL is the OpenID provider's endpoint URL with
132
+ # parameters appended as query arguments. You should redirect
133
+ # the user agent to this URL.
134
+ #
135
+ # OpenID 2.0 endpoints also accept POST requests, see
136
+ # 'send_redirect?' and 'form_markup'.
137
+ def redirect_url(realm, return_to = nil, immediate = false)
138
+ message = get_message(realm, return_to, immediate)
139
+ message.to_url(@endpoint.server_url)
140
+ end
141
+
142
+ # Get html for a form to submit this request to the IDP.
143
+ #
144
+ # form_tag_attrs is a hash of attributes to be added to the form
145
+ # tag. 'accept-charset' and 'enctype' have defaults that can be
146
+ # overridden. If a value is supplied for 'action' or 'method',
147
+ # it will be replaced.
148
+ def form_markup(realm, return_to = nil, immediate = false,
149
+ form_tag_attrs = nil)
150
+ message = get_message(realm, return_to, immediate)
151
+ message.to_form_markup(@endpoint.server_url, form_tag_attrs)
152
+ end
153
+
154
+ # Get a complete HTML document that autosubmits the request to the IDP
155
+ # with javascript. This method wraps form_markup - see that method's
156
+ # documentation for help with the parameters.
157
+ def html_markup(realm, return_to = nil, immediate = false,
158
+ form_tag_attrs = nil)
159
+ Util.auto_submit_html(form_markup(
160
+ realm,
161
+ return_to,
162
+ immediate,
163
+ form_tag_attrs,
164
+ ))
165
+ end
166
+
167
+ # Should this OpenID authentication request be sent as a HTTP
168
+ # redirect or as a POST (form submission)?
169
+ #
170
+ # This takes the same parameters as redirect_url or form_markup
171
+ def send_redirect?(realm, return_to = nil, immediate = false)
172
+ return true if @endpoint.compatibility_mode
173
+
174
+ url = redirect_url(realm, return_to, immediate)
175
+ url.length <= OPENID1_URL_LIMIT
176
+ end
177
+ end
178
+ end
179
+ end