ruby-openid2 3.0.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.
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