C0 code coverage information
Generated on Fri Jul 11 15:55:34 -0700 2008 with rcov 0.7.0
Code reported as executed by Ruby looks like this...
and this: this line is also marked as covered.
Lines considered as run by rcov, but not reported by Ruby, look like this,
and this: these lines were inferred by rcov (using simple heuristics).
Finally, here's a line marked as not executed.
1
2 require 'openid/cryptutil'
3 require 'openid/util'
4 require 'openid/dh'
5 require 'openid/store/nonce'
6 require 'openid/trustroot'
7 require 'openid/association'
8 require 'openid/message'
9
10 require 'time'
11
12 module OpenID
13
14 module Server
15
16 HTTP_OK = 200
17 HTTP_REDIRECT = 302
18 HTTP_ERROR = 400
19
20 BROWSER_REQUEST_MODES = ['checkid_setup', 'checkid_immediate']
21
22 ENCODE_KVFORM = ['kvform'].freeze
23 ENCODE_URL = ['URL/redirect'].freeze
24 ENCODE_HTML_FORM = ['HTML form'].freeze
25
26 UNUSED = nil
27
28 class OpenIDRequest
29 attr_accessor :message, :mode
30
31 # I represent an incoming OpenID request.
32 #
33 # Attributes:
34 # mode:: The "openid.mode" of this request
35 def initialize
36 @mode = nil
37 @message = nil
38 end
39
40 def namespace
41 if @message.nil?
42 raise RuntimeError, "Request has no message"
43 else
44 return @message.get_openid_namespace
45 end
46 end
47 end
48
49 # A request to verify the validity of a previous response.
50 #
51 # See OpenID Specs, Verifying Directly with the OpenID Provider
52 # <http://openid.net/specs/openid-authentication-2_0-12.html#verifying_signatures>
53 class CheckAuthRequest < OpenIDRequest
54
55 # The association handle the response was signed with.
56 attr_accessor :assoc_handle
57
58 # The message with the signature which wants checking.
59 attr_accessor :signed
60
61 # An association handle the client is asking about the validity
62 # of. May be nil.
63 attr_accessor :invalidate_handle
64
65 attr_accessor :sig
66
67 # Construct me.
68 #
69 # These parameters are assigned directly as class attributes.
70 #
71 # Parameters:
72 # assoc_handle:: the association handle for this request
73 # signed:: The signed message
74 # invalidate_handle:: An association handle that the relying
75 # party is checking to see if it is invalid
76 def initialize(assoc_handle, signed, invalidate_handle=nil)
77 super()
78
79 @mode = "check_authentication"
80 @required_fields = ["identity", "return_to", "response_nonce"].freeze
81
82 @sig = nil
83 @assoc_handle = assoc_handle
84 @signed = signed
85 @invalidate_handle = invalidate_handle
86 end
87
88 # Construct me from an OpenID::Message.
89 def self.from_message(message, op_endpoint=UNUSED)
90 assoc_handle = message.get_arg(OPENID_NS, 'assoc_handle')
91 invalidate_handle = message.get_arg(OPENID_NS, 'invalidate_handle')
92
93 signed = message.copy()
94 # openid.mode is currently check_authentication because
95 # that's the mode of this request. But the signature
96 # was made on something with a different openid.mode.
97 # http://article.gmane.org/gmane.comp.web.openid.general/537
98 if signed.has_key?(OPENID_NS, "mode")
99 signed.set_arg(OPENID_NS, "mode", "id_res")
100 end
101
102 obj = self.new(assoc_handle, signed, invalidate_handle)
103 obj.message = message
104 obj.sig = message.get_arg(OPENID_NS, 'sig')
105
106 if !obj.assoc_handle or
107 !obj.sig
108 msg = sprintf("%s request missing required parameter from message %s",
109 obj.mode, message)
110 raise ProtocolError.new(message, msg)
111 end
112
113 return obj
114 end
115
116 # Respond to this request.
117 #
118 # Given a Signatory, I can check the validity of the signature
119 # and the invalidate_handle. I return a response with an
120 # is_valid (and, if appropriate invalidate_handle) field.
121 def answer(signatory)
122 is_valid = signatory.verify(@assoc_handle, @signed)
123 # Now invalidate that assoc_handle so it this checkAuth
124 # message cannot be replayed.
125 signatory.invalidate(@assoc_handle, dumb=true)
126 response = OpenIDResponse.new(self)
127 valid_str = is_valid ? "true" : "false"
128 response.fields.set_arg(OPENID_NS, 'is_valid', valid_str)
129
130 if @invalidate_handle
131 assoc = signatory.get_association(@invalidate_handle, false)
132 if !assoc
133 response.fields.set_arg(
134 OPENID_NS, 'invalidate_handle', @invalidate_handle)
135 end
136 end
137
138 return response
139 end
140
141 def to_s
142 ih = nil
143
144 if @invalidate_handle
145 ih = sprintf(" invalidate? %s", @invalidate_handle)
146 else
147 ih = ""
148 end
149
150 s = sprintf("<%s handle: %s sig: %s: signed: %s%s>",
151 self.class, @assoc_handle,
152 @sig, @signed, ih)
153 return s
154 end
155 end
156
157 class BaseServerSession
158 attr_reader :session_type
159
160 def initialize(session_type, allowed_assoc_types)
161 @session_type = session_type
162 @allowed_assoc_types = allowed_assoc_types.dup.freeze
163 end
164
165 def allowed_assoc_type?(typ)
166 @allowed_assoc_types.member?(typ)
167 end
168 end
169
170 # An object that knows how to handle association requests with
171 # no session type.
172 #
173 # See OpenID Specs, Section 8: Establishing Associations
174 # <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
175 class PlainTextServerSession < BaseServerSession
176 # The session_type for this association session. There is no
177 # type defined for plain-text in the OpenID specification, so we
178 # use 'no-encryption'.
179 attr_reader :session_type
180
181 def initialize
182 super('no-encryption', ['HMAC-SHA1', 'HMAC-SHA256'])
183 end
184
185 def self.from_message(unused_request)
186 return self.new
187 end
188
189 def answer(secret)
190 return {'mac_key' => Util.to_base64(secret)}
191 end
192 end
193
194 # An object that knows how to handle association requests with the
195 # Diffie-Hellman session type.
196 #
197 # See OpenID Specs, Section 8: Establishing Associations
198 # <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
199 class DiffieHellmanSHA1ServerSession < BaseServerSession
200
201 # The Diffie-Hellman algorithm values for this request
202 attr_accessor :dh
203
204 # The public key sent by the consumer in the associate request
205 attr_accessor :consumer_pubkey
206
207 # The session_type for this association session.
208 attr_reader :session_type
209
210 def initialize(dh, consumer_pubkey)
211 super('DH-SHA1', ['HMAC-SHA1'])
212
213 @hash_func = CryptUtil.method('sha1')
214 @dh = dh
215 @consumer_pubkey = consumer_pubkey
216 end
217
218 # Construct me from OpenID Message
219 #
220 # Raises ProtocolError when parameters required to establish the
221 # session are missing.
222 def self.from_message(message)
223 dh_modulus = message.get_arg(OPENID_NS, 'dh_modulus')
224 dh_gen = message.get_arg(OPENID_NS, 'dh_gen')
225 if ((!dh_modulus and dh_gen) or
226 (!dh_gen and dh_modulus))
227
228 if !dh_modulus
229 missing = 'modulus'
230 else
231 missing = 'generator'
232 end
233
234 raise ProtocolError.new(message,
235 sprintf('If non-default modulus or generator is ' +
236 'supplied, both must be supplied. Missing %s',
237 missing))
238 end
239
240 if dh_modulus or dh_gen
241 dh_modulus = CryptUtil.base64_to_num(dh_modulus)
242 dh_gen = CryptUtil.base64_to_num(dh_gen)
243 dh = DiffieHellman.new(dh_modulus, dh_gen)
244 else
245 dh = DiffieHellman.from_defaults()
246 end
247
248 consumer_pubkey = message.get_arg(OPENID_NS, 'dh_consumer_public')
249 if !consumer_pubkey
250 raise ProtocolError.new(message,
251 sprintf("Public key for DH-SHA1 session " +
252 "not found in message %s", message))
253 end
254
255 consumer_pubkey = CryptUtil.base64_to_num(consumer_pubkey)
256
257 return self.new(dh, consumer_pubkey)
258 end
259
260 def answer(secret)
261 mac_key = @dh.xor_secret(@hash_func,
262 @consumer_pubkey,
263 secret)
264 return {
265 'dh_server_public' => CryptUtil.num_to_base64(@dh.public),
266 'enc_mac_key' => Util.to_base64(mac_key),
267 }
268 end
269 end
270
271 class DiffieHellmanSHA256ServerSession < DiffieHellmanSHA1ServerSession
272 def initialize(*args)
273 super(*args)
274 @session_type = 'DH-SHA256'
275 @hash_func = CryptUtil.method('sha256')
276 @allowed_assoc_types = ['HMAC-SHA256'].freeze
277 end
278 end
279
280 # A request to establish an association.
281 #
282 # See OpenID Specs, Section 8: Establishing Associations
283 # <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
284 class AssociateRequest < OpenIDRequest
285 # An object that knows how to handle association requests of a
286 # certain type.
287 attr_accessor :session
288
289 # The type of association. Supported values include HMAC-SHA256
290 # and HMAC-SHA1
291 attr_accessor :assoc_type
292
293 @@session_classes = {
294 'no-encryption' => PlainTextServerSession,
295 'DH-SHA1' => DiffieHellmanSHA1ServerSession,
296 'DH-SHA256' => DiffieHellmanSHA256ServerSession,
297 }
298
299 # Construct me.
300 #
301 # The session is assigned directly as a class attribute. See my
302 # class documentation for its description.
303 def initialize(session, assoc_type)
304 super()
305 @session = session
306 @assoc_type = assoc_type
307
308 @mode = "associate"
309 end
310
311 # Construct me from an OpenID Message.
312 def self.from_message(message, op_endpoint=UNUSED)
313 if message.is_openid1()
314 session_type = message.get_arg(OPENID_NS, 'session_type')
315 if session_type == 'no-encryption'
316 Util.log('Received OpenID 1 request with a no-encryption ' +
317 'association session type. Continuing anyway.')
318 elsif !session_type
319 session_type = 'no-encryption'
320 end
321 else
322 session_type = message.get_arg(OPENID2_NS, 'session_type')
323 if !session_type
324 raise ProtocolError.new(message,
325 text="session_type missing from request")
326 end
327 end
328
329 session_class = @@session_classes[session_type]
330
331 if !session_class
332 raise ProtocolError.new(message,
333 sprintf("Unknown session type %s", session_type))
334 end
335
336 begin
337 session = session_class.from_message(message)
338 rescue ArgumentError => why
339 # XXX
340 raise ProtocolError.new(message,
341 sprintf('Error parsing %s session: %s',
342 session_type, why))
343 end
344
345 assoc_type = message.get_arg(OPENID_NS, 'assoc_type', 'HMAC-SHA1')
346 if !session.allowed_assoc_type?(assoc_type)
347 msg = sprintf('Session type %s does not support association type %s',
348 session_type, assoc_type)
349 raise ProtocolError.new(message, msg)
350 end
351
352 obj = self.new(session, assoc_type)
353 obj.message = message
354 return obj
355 end
356
357 # Respond to this request with an association.
358 #
359 # assoc:: The association to send back.
360 #
361 # Returns a response with the association information, encrypted
362 # to the consumer's public key if appropriate.
363 def answer(assoc)
364 response = OpenIDResponse.new(self)
365 response.fields.update_args(OPENID_NS, {
366 'expires_in' => sprintf('%d', assoc.expires_in()),
367 'assoc_type' => @assoc_type,
368 'assoc_handle' => assoc.handle,
369 })
370 response.fields.update_args(OPENID_NS,
371 @session.answer(assoc.secret))
372 unless (@session.session_type == 'no-encryption' and
373 @message.is_openid1)
374 response.fields.set_arg(
375 OPENID_NS, 'session_type', @session.session_type)
376 end
377
378 return response
379 end
380
381 # Respond to this request indicating that the association type
382 # or association session type is not supported.
383 def answer_unsupported(message, preferred_association_type=nil,
384 preferred_session_type=nil)
385 if @message.is_openid1()
386 raise ProtocolError.new(@message)
387 end
388
389 response = OpenIDResponse.new(self)
390 response.fields.set_arg(OPENID_NS, 'error_code', 'unsupported-type')
391 response.fields.set_arg(OPENID_NS, 'error', message)
392
393 if preferred_association_type
394 response.fields.set_arg(
395 OPENID_NS, 'assoc_type', preferred_association_type)
396 end
397
398 if preferred_session_type
399 response.fields.set_arg(
400 OPENID_NS, 'session_type', preferred_session_type)
401 end
402
403 return response
404 end
405 end
406
407 # A request to confirm the identity of a user.
408 #
409 # This class handles requests for openid modes
410 # +checkid_immediate+ and +checkid_setup+ .
411 class CheckIDRequest < OpenIDRequest
412
413 # Provided in smart mode requests, a handle for a previously
414 # established association. nil for dumb mode requests.
415 attr_accessor :assoc_handle
416
417 # Is this an immediate-mode request?
418 attr_accessor :immediate
419
420 # The URL to send the user agent back to to reply to this
421 # request.
422 attr_accessor :return_to
423
424 # The OP-local identifier being checked.
425 attr_accessor :identity
426
427 # The claimed identifier. Not present in OpenID 1.x
428 # messages.
429 attr_accessor :claimed_id
430
431 # This URL identifies the party making the request, and the user
432 # will use that to make her decision about what answer she
433 # trusts them to have. Referred to as "realm" in OpenID 2.0.
434 attr_accessor :trust_root
435
436 # mode:: +checkid_immediate+ or +checkid_setup+
437 attr_accessor :mode
438
439 attr_accessor :op_endpoint
440
441 # These parameters are assigned directly as attributes,
442 # see the #CheckIDRequest class documentation for their
443 # descriptions.
444 #
445 # Raises #MalformedReturnURL when the +return_to+ URL is not
446 # a URL.
447 def initialize(identity, return_to, op_endpoint, trust_root=nil,
448 immediate=false, assoc_handle=nil, claimed_id=nil)
449 @assoc_handle = assoc_handle
450 @identity = identity
451 @claimed_id = (claimed_id or identity)
452 @return_to = return_to
453 @trust_root = (trust_root or return_to)
454 @op_endpoint = op_endpoint
455 @message = nil
456
457 if immediate
458 @immediate = true
459 @mode = "checkid_immediate"
460 else
461 @immediate = false
462 @mode = "checkid_setup"
463 end
464
465 if @return_to and
466 !TrustRoot::TrustRoot.parse(@return_to)
467 raise MalformedReturnURL.new(nil, @return_to)
468 end
469
470 if !trust_root_valid()
471 raise UntrustedReturnURL.new(nil, @return_to, @trust_root)
472 end
473 end
474
475 # Construct me from an OpenID message.
476 #
477 # message:: An OpenID checkid_* request Message
478 #
479 # op_endpoint:: The endpoint URL of the server that this
480 # message was sent to.
481 #
482 # Raises:
483 # ProtocolError:: When not all required parameters are present
484 # in the message.
485 #
486 # MalformedReturnURL:: When the +return_to+ URL is not a URL.
487 #
488 # UntrustedReturnURL:: When the +return_to+ URL is
489 # outside the +trust_root+.
490 def self.from_message(message, op_endpoint)
491 obj = self.allocate
492 obj.message = message
493 obj.op_endpoint = op_endpoint
494 mode = message.get_arg(OPENID_NS, 'mode')
495 if mode == "checkid_immediate"
496 obj.immediate = true
497 obj.mode = "checkid_immediate"
498 else
499 obj.immediate = false
500 obj.mode = "checkid_setup"
501 end
502
503 obj.return_to = message.get_arg(OPENID_NS, 'return_to')
504 if message.is_openid1 and !obj.return_to
505 msg = sprintf("Missing required field 'return_to' from %s",
506 message)
507 raise ProtocolError.new(message, msg)
508 end
509
510 obj.identity = message.get_arg(OPENID_NS, 'identity')
511 obj.claimed_id = message.get_arg(OPENID_NS, 'claimed_id')
512 if message.is_openid1()
513 if !obj.identity
514 s = "OpenID 1 message did not contain openid.identity"
515 raise ProtocolError.new(message, s)
516 end
517 else
518 if obj.identity and not obj.claimed_id
519 s = ("OpenID 2.0 message contained openid.identity but not " +
520 "claimed_id")
521 raise ProtocolError.new(message, s)
522 elsif obj.claimed_id and not obj.identity
523 s = ("OpenID 2.0 message contained openid.claimed_id but not " +
524 "identity")
525 raise ProtocolError.new(message, s)
526 end
527 end
528
529 # There's a case for making self.trust_root be a TrustRoot
530 # here. But if TrustRoot isn't currently part of the "public"
531 # API, I'm not sure it's worth doing.
532 if message.is_openid1
533 trust_root_param = 'trust_root'
534 else
535 trust_root_param = 'realm'
536 end
537 trust_root = message.get_arg(OPENID_NS, trust_root_param)
538 trust_root = obj.return_to if (trust_root.nil? || trust_root.empty?)
539 obj.trust_root = trust_root
540
541 if !message.is_openid1 and !obj.return_to and !obj.trust_root
542 raise ProtocolError.new(message, "openid.realm required when " +
543 "openid.return_to absent")
544 end
545
546 obj.assoc_handle = message.get_arg(OPENID_NS, 'assoc_handle')
547
548 # Using TrustRoot.parse here is a bit misleading, as we're not
549 # parsing return_to as a trust root at all. However, valid
550 # URLs are valid trust roots, so we can use this to get an
551 # idea if it is a valid URL. Not all trust roots are valid
552 # return_to URLs, however (particularly ones with wildcards),
553 # so this is still a little sketchy.
554 if obj.return_to and \
555 !TrustRoot::TrustRoot.parse(obj.return_to)
556 raise MalformedReturnURL.new(message, obj.return_to)
557 end
558
559 # I first thought that checking to see if the return_to is
560 # within the trust_root is premature here, a
561 # logic-not-decoding thing. But it was argued that this is
562 # really part of data validation. A request with an invalid
563 # trust_root/return_to is broken regardless of application,
564 # right?
565 if !obj.trust_root_valid()
566 raise UntrustedReturnURL.new(message, obj.return_to, obj.trust_root)
567 end
568
569 return obj
570 end
571
572 # Is the identifier to be selected by the IDP?
573 def id_select
574 # So IDPs don't have to import the constant
575 return @identity == IDENTIFIER_SELECT
576 end
577
578 # Is my return_to under my trust_root?
579 def trust_root_valid
580 if !@trust_root
581 return true
582 end
583
584 tr = TrustRoot::TrustRoot.parse(@trust_root)
585 if !tr
586 raise MalformedTrustRoot.new(@message, @trust_root)
587 end
588
589 if @return_to
590 return tr.validate_url(@return_to)
591 else
592 return true
593 end
594 end
595
596 # Does the relying party publish the return_to URL for this
597 # response under the realm? It is up to the provider to set a
598 # policy for what kinds of realms should be allowed. This
599 # return_to URL verification reduces vulnerability to
600 # data-theft attacks based on open proxies,
601 # corss-site-scripting, or open redirectors.
602 #
603 # This check should only be performed after making sure that
604 # the return_to URL matches the realm.
605 #
606 # Raises DiscoveryFailure if the realm
607 # URL does not support Yadis discovery (and so does not
608 # support the verification process).
609 #
610 # Returns true if the realm publishes a document with the
611 # return_to URL listed
612 def return_to_verified
613 return TrustRoot.verify_return_to(@trust_root, @return_to)
614 end
615
616 # Respond to this request.
617 #
618 # allow:: Allow this user to claim this identity, and allow the
619 # consumer to have this information?
620 #
621 # server_url:: DEPRECATED. Passing op_endpoint to the
622 # #Server constructor makes this optional.
623 #
624 # When an OpenID 1.x immediate mode request does
625 # not succeed, it gets back a URL where the request
626 # may be carried out in a not-so-immediate fashion.
627 # Pass my URL in here (the fully qualified address
628 # of this server's endpoint, i.e.
629 # <tt>http://example.com/server</tt>), and I will
630 # use it as a base for the URL for a new request.
631 #
632 # Optional for requests where
633 # #CheckIDRequest.immediate is false or +allow+ is
634 # true.
635 #
636 # identity:: The OP-local identifier to answer with. Only for use
637 # when the relying party requested identifier selection.
638 #
639 # claimed_id:: The claimed identifier to answer with,
640 # for use with identifier selection in the case where the
641 # claimed identifier and the OP-local identifier differ,
642 # i.e. when the claimed_id uses delegation.
643 #
644 # If +identity+ is provided but this is not,
645 # +claimed_id+ will default to the value of +identity+.
646 # When answering requests that did not ask for identifier
647 # selection, the response +claimed_id+ will default to
648 # that of the request.
649 #
650 # This parameter is new in OpenID 2.0.
651 #
652 # Returns an OpenIDResponse object containing a OpenID id_res message.
653 #
654 # Raises NoReturnToError if the return_to is missing.
655 #
656 # Version 2.0 deprecates +server_url+ and adds +claimed_id+.
657 def answer(allow, server_url=nil, identity=nil, claimed_id=nil)
658 if !@return_to
659 raise NoReturnToError
660 end
661
662 if !server_url
663 if @message.is_openid2 and !@op_endpoint
664 # In other words, that warning I raised in
665 # Server.__init__? You should pay attention to it now.
666 raise RuntimeError, ("#{self} should be constructed with "\
667 "op_endpoint to respond to OpenID 2.0 "\
668 "messages.")
669 end
670
671 server_url = @op_endpoint
672 end
673
674 if allow
675 mode = 'id_res'
676 elsif @message.is_openid1
677 if @immediate
678 mode = 'id_res'
679 else
680 mode = 'cancel'
681 end
682 else
683 if @immediate
684 mode = 'setup_needed'
685 else
686 mode = 'cancel'
687 end
688 end
689
690 response = OpenIDResponse.new(self)
691
692 if claimed_id and @message.is_openid1
693 raise VersionError, ("claimed_id is new in OpenID 2.0 and not "\
694 "available for #{@message.get_openid_namespace}")
695 end
696
697 if identity and !claimed_id
698 claimed_id = identity
699 end
700
701 if allow
702 if @identity == IDENTIFIER_SELECT
703 if !identity
704 raise ArgumentError, ("This request uses IdP-driven "\
705 "identifier selection.You must supply "\
706 "an identifier in the response.")
707 end
708
709 response_identity = identity
710 response_claimed_id = claimed_id
711
712 elsif @identity
713 if identity and (@identity != identity)
714 raise ArgumentError, ("Request was for identity #{@identity}, "\
715 "cannot reply with identity #{identity}")
716 end
717
718 response_identity = @identity
719 response_claimed_id = @claimed_id
720 else
721 if identity
722 raise ArgumentError, ("This request specified no identity "\
723 "and you supplied #{identity}")
724 end
725 response_identity = nil
726 end
727
728 if @message.is_openid1 and !response_identity
729 raise ArgumentError, ("Request was an OpenID 1 request, so "\
730 "response must include an identifier.")
731 end
732
733 response.fields.update_args(OPENID_NS, {
734 'mode' => mode,
735 'op_endpoint' => server_url,
736 'return_to' => @return_to,
737 'response_nonce' => Nonce.mk_nonce(),
738 })
739
740 if response_identity
741 response.fields.set_arg(OPENID_NS, 'identity', response_identity)
742 if @message.is_openid2
743 response.fields.set_arg(OPENID_NS,
744 'claimed_id', response_claimed_id)
745 end
746 end
747 else
748 response.fields.set_arg(OPENID_NS, 'mode', mode)
749 if @immediate
750 if @message.is_openid1 and !server_url
751 raise ArgumentError, ("setup_url is required for allow=false "\
752 "in OpenID 1.x immediate mode.")
753 end
754
755 # Make a new request just like me, but with
756 # immediate=false.
757 setup_request = self.class.new(@identity, @return_to,
758 @op_endpoint, @trust_root, false,
759 @assoc_handle, @claimed_id)
760 setup_request.message = Message.new(@message.get_openid_namespace)
761 setup_url = setup_request.encode_to_url(server_url)
762 response.fields.set_arg(OPENID_NS, 'user_setup_url', setup_url)
763 end
764 end
765
766 return response
767 end
768
769 def encode_to_url(server_url)
770 # Encode this request as a URL to GET.
771 #
772 # server_url:: The URL of the OpenID server to make this
773 # request of.
774 if !@return_to
775 raise NoReturnToError
776 end
777
778 # Imported from the alternate reality where these classes are
779 # used in both the client and server code, so Requests are
780 # Encodable too. That's right, code imported from alternate
781 # realities all for the love of you, id_res/user_setup_url.
782 q = {'mode' => @mode,
783 'identity' => @identity,
784 'claimed_id' => @claimed_id,
785 'return_to' => @return_to}
786
787 if @trust_root
788 if @message.is_openid1
789 q['trust_root'] = @trust_root
790 else
791 q['realm'] = @trust_root
792 end
793 end
794
795 if @assoc_handle
796 q['assoc_handle'] = @assoc_handle
797 end
798
799 response = Message.new(@message.get_openid_namespace)
800 response.update_args(@message.get_openid_namespace, q)
801 return response.to_url(server_url)
802 end
803
804 def cancel_url
805 # Get the URL to cancel this request.
806 #
807 # Useful for creating a "Cancel" button on a web form so that
808 # operation can be carried out directly without another trip
809 # through the server.
810 #
811 # (Except you may want to make another trip through the
812 # server so that it knows that the user did make a decision.)
813 #
814 # Returns a URL as a string.
815 if !@return_to
816 raise NoReturnToError
817 end
818
819 if @immediate
820 raise ArgumentError.new("Cancel is not an appropriate response to " +
821 "immediate mode requests.")
822 end
823
824 response = Message.new(@message.get_openid_namespace)
825 response.set_arg(OPENID_NS, 'mode', 'cancel')
826 return response.to_url(@return_to)
827 end
828
829 def to_s
830 return sprintf('<%s id:%s im:%s tr:%s ah:%s>', self.class,
831 @identity,
832 @immediate,
833 @trust_root,
834 @assoc_handle)
835 end
836 end
837
838 # I am a response to an OpenID request.
839 #
840 # Attributes:
841 # signed:: A list of the names of the fields which should be signed.
842 #
843 # Implementer's note: In a more symmetric client/server
844 # implementation, there would be more types of #OpenIDResponse
845 # object and they would have validated attributes according to
846 # the type of response. But as it is, Response objects in a
847 # server are basically write-only, their only job is to go out
848 # over the wire, so this is just a loose wrapper around
849 # #OpenIDResponse.fields.
850 class OpenIDResponse
851 # The #OpenIDRequest I respond to.
852 attr_accessor :request
853
854 # An #OpenID::Message with the data to be returned.
855 # Keys are parameter names with no
856 # leading openid. e.g. identity and mac_key
857 # never openid.identity.
858 attr_accessor :fields
859
860 def initialize(request)
861 # Make a response to an OpenIDRequest.
862 @request = request
863 @fields = Message.new(request.namespace)
864 end
865
866 def to_s
867 return sprintf("%s for %s: %s",
868 self.class,
869 @request.class,
870 @fields)
871 end
872
873 # form_tag_attrs is a hash of attributes to be added to the form
874 # tag. 'accept-charset' and 'enctype' have defaults that can be
875 # overridden. If a value is supplied for 'action' or 'method',
876 # it will be replaced.
877 # Returns the form markup for this response.
878 def to_form_markup(form_tag_attrs=nil)
879 return @fields.to_form_markup(@request.return_to, form_tag_attrs)
880 end
881
882 # Wraps the form tag from to_form_markup in a complete HTML document
883 # that uses javascript to autosubmit the form.
884 def to_html(form_tag_attrs=nil)
885 return Util.auto_submit_html(to_form_markup(form_tag_attrs))
886 end
887
888 def render_as_form
889 # Returns true if this response's encoding is
890 # ENCODE_HTML_FORM. Convenience method for server authors.
891 return self.which_encoding == ENCODE_HTML_FORM
892 end
893
894 def needs_signing
895 # Does this response require signing?
896 return @fields.get_arg(OPENID_NS, 'mode') == 'id_res'
897 end
898
899 # implements IEncodable
900
901 def which_encoding
902 # How should I be encoded?
903 # returns one of ENCODE_URL or ENCODE_KVFORM.
904 if BROWSER_REQUEST_MODES.member?(@request.mode)
905 if @fields.is_openid2 and
906 encode_to_url.length > OPENID1_URL_LIMIT
907 return ENCODE_HTML_FORM
908 else
909 return ENCODE_URL
910 end
911 else
912 return ENCODE_KVFORM
913 end
914 end
915
916 def encode_to_url
917 # Encode a response as a URL for the user agent to GET.
918 # You will generally use this URL with a HTTP redirect.
919 return @fields.to_url(@request.return_to)
920 end
921
922 def add_extension(extension_response)
923 # Add an extension response to this response message.
924 #
925 # extension_response:: An object that implements the
926 # #OpenID::Extension interface for adding arguments to an OpenID
927 # message.
928 extension_response.to_message(@fields)
929 end
930
931 def encode_to_kvform
932 # Encode a response in key-value colon/newline format.
933 #
934 # This is a machine-readable format used to respond to
935 # messages which came directly from the consumer and not
936 # through the user agent.
937 #
938 # see: OpenID Specs,
939 # <a href="http://openid.net/specs.bml#keyvalue">Key-Value Colon/Newline format</a>
940 return @fields.to_kvform
941 end
942
943 def copy
944 return Marshal.load(Marshal.dump(self))
945 end
946 end
947
948 # I am a response to an OpenID request in terms a web server
949 # understands.
950 #
951 # I generally come from an #Encoder, either directly or from
952 # #Server.encodeResponse.
953 class WebResponse
954
955 # The HTTP code of this response as an integer.
956 attr_accessor :code
957
958 # #Hash of headers to include in this response.
959 attr_accessor :headers
960
961 # The body of this response.
962 attr_accessor :body
963
964 def initialize(code=HTTP_OK, headers=nil, body="")
965 # Construct me.
966 #
967 # These parameters are assigned directly as class attributes,
968 # see my class documentation for their
969 # descriptions.
970 @code = code
971 if headers
972 @headers = headers
973 else
974 @headers = {}
975 end
976 @body = body
977 end
978 end
979
980 # I sign things.
981 #
982 # I also check signatures.
983 #
984 # All my state is encapsulated in a store, which means I'm not
985 # generally pickleable but I am easy to reconstruct.
986 class Signatory
987 # The number of seconds a secret remains valid. Defaults to 14 days.
988 attr_accessor :secret_lifetime
989
990 # keys have a bogus server URL in them because the filestore
991 # really does expect that key to be a URL. This seems a little
992 # silly for the server store, since I expect there to be only
993 # one server URL.
994 @@_normal_key = 'http://localhost/|normal'
995 @@_dumb_key = 'http://localhost/|dumb'
996
997 def self._normal_key
998 @@_normal_key
999 end
1000
1001 def self._dumb_key
1002 @@_dumb_key
1003 end
1004
1005 attr_accessor :store
1006
1007 # Create a new Signatory. store is The back-end where my
1008 # associations are stored.
1009 def initialize(store)
1010 Util.assert(store)
1011 @store = store
1012 @secret_lifetime = 14 * 24 * 60 * 60
1013 end
1014
1015 # Verify that the signature for some data is valid.
1016 def verify(assoc_handle, message)
1017 assoc = get_association(assoc_handle, true)
1018 if !assoc
1019 Util.log(sprintf("failed to get assoc with handle %s to verify " +
1020 "message %s", assoc_handle, message))
1021 return false
1022 end
1023
1024 begin
1025 valid = assoc.check_message_signature(message)
1026 rescue StandardError => ex
1027 Util.log(sprintf("Error in verifying %s with %s: %s",
1028 message, assoc, ex))
1029 return false
1030 end
1031
1032 return valid
1033 end
1034
1035 # Sign a response.
1036 #
1037 # I take an OpenIDResponse, create a signature for everything in
1038 # its signed list, and return a new copy of the response object
1039 # with that signature included.
1040 def sign(response)
1041 signed_response = response.copy
1042 assoc_handle = response.request.assoc_handle
1043 if assoc_handle
1044 # normal mode disabling expiration check because even if the
1045 # association is expired, we still need to know some
1046 # properties of the association so that we may preserve
1047 # those properties when creating the fallback association.
1048 assoc = get_association(assoc_handle, false, false)
1049
1050 if !assoc or assoc.expires_in <= 0
1051 # fall back to dumb mode
1052 signed_response.fields.set_arg(
1053 OPENID_NS, 'invalidate_handle', assoc_handle)
1054 assoc_type = assoc ? assoc.assoc_type : 'HMAC-SHA1'
1055 if assoc and assoc.expires_in <= 0
1056 # now do the clean-up that the disabled checkExpiration
1057 # code didn't get to do.
1058 invalidate(assoc_handle, false)
1059 end
1060 assoc = create_association(true, assoc_type)
1061 end
1062 else
1063 # dumb mode.
1064 assoc = create_association(true)
1065 end
1066
1067 begin
1068 signed_response.fields = assoc.sign_message(signed_response.fields)
1069 rescue KVFormError => err
1070 raise EncodingError, err
1071 end
1072 return signed_response
1073 end
1074
1075 # Make a new association.
1076 def create_association(dumb=true, assoc_type='HMAC-SHA1')
1077 secret = CryptUtil.random_string(OpenID.get_secret_size(assoc_type))
1078 uniq = Util.to_base64(CryptUtil.random_string(4))
1079 handle = sprintf('{%s}{%x}{%s}', assoc_type, Time.now.to_i, uniq)
1080
1081 assoc = Association.from_expires_in(
1082 secret_lifetime, handle, secret, assoc_type)
1083
1084 if dumb
1085 key = @@_dumb_key
1086 else
1087 key = @@_normal_key
1088 end
1089
1090 @store.store_association(key, assoc)
1091 return assoc
1092 end
1093
1094 # Get the association with the specified handle.
1095 def get_association(assoc_handle, dumb, checkExpiration=true)
1096 # Hmm. We've created an interface that deals almost entirely
1097 # with assoc_handles. The only place outside the Signatory
1098 # that uses this (and thus the only place that ever sees
1099 # Association objects) is when creating a response to an
1100 # association request, as it must have the association's
1101 # secret.
1102
1103 if !assoc_handle
1104 raise ArgumentError.new("assoc_handle must not be None")
1105 end
1106
1107 if dumb
1108 key = @@_dumb_key
1109 else
1110 key = @@_normal_key
1111 end
1112
1113 assoc = @store.get_association(key, assoc_handle)
1114 if assoc and assoc.expires_in <= 0
1115 Util.log(sprintf("requested %sdumb key %s is expired (by %s seconds)",
1116 (!dumb) ? 'not-' : '',
1117 assoc_handle, assoc.expires_in))
1118 if checkExpiration
1119 @store.remove_association(key, assoc_handle)
1120 assoc = nil
1121 end
1122 end
1123
1124 return assoc
1125 end
1126
1127 # Invalidates the association with the given handle.
1128 def invalidate(assoc_handle, dumb)
1129 if dumb
1130 key = @@_dumb_key
1131 else
1132 key = @@_normal_key
1133 end
1134
1135 @store.remove_association(key, assoc_handle)
1136 end
1137 end
1138
1139 # I encode responses in to WebResponses.
1140 #
1141 # If you don't like WebResponses, you can do
1142 # your own handling of OpenIDResponses with
1143 # OpenIDResponse.whichEncoding,
1144 # OpenIDResponse.encodeToURL, and
1145 # OpenIDResponse.encodeToKVForm.
1146 class Encoder
1147 @@responseFactory = WebResponse
1148
1149 # Encode a response to a WebResponse.
1150 #
1151 # Raises EncodingError when I can't figure out how to encode
1152 # this message.
1153 def encode(response)
1154 encode_as = response.which_encoding()
1155 if encode_as == ENCODE_KVFORM
1156 wr = @@responseFactory.new(HTTP_OK, nil,
1157 response.encode_to_kvform())
1158 if response.is_a?(Exception)
1159 wr.code = HTTP_ERROR
1160 end
1161 elsif encode_as == ENCODE_URL
1162 location = response.encode_to_url()
1163 wr = @@responseFactory.new(HTTP_REDIRECT,
1164 {'location' => location})
1165 elsif encode_as == ENCODE_HTML_FORM
1166 wr = @@responseFactory.new(HTTP_OK, nil,
1167 response.to_form_markup())
1168 else
1169 # Can't encode this to a protocol message. You should
1170 # probably render it to HTML and show it to the user.
1171 raise EncodingError.new(response)
1172 end
1173
1174 return wr
1175 end
1176 end
1177
1178 # I encode responses in to WebResponses, signing
1179 # them when required.
1180 class SigningEncoder < Encoder
1181
1182 attr_accessor :signatory
1183
1184 # Create a SigningEncoder given a Signatory
1185 def initialize(signatory)
1186 @signatory = signatory
1187 end
1188
1189 # Encode a response to a WebResponse, signing it first if
1190 # appropriate.
1191 #
1192 # Raises EncodingError when I can't figure out how to encode this
1193 # message.
1194 #
1195 # Raises AlreadySigned when this response is already signed.
1196 def encode(response)
1197 # the is_a? is a bit of a kludge... it means there isn't
1198 # really an adapter to make the interfaces quite match.
1199 if !response.is_a?(Exception) and response.needs_signing()
1200 if !@signatory
1201 raise ArgumentError.new(
1202 sprintf("Must have a store to sign this request: %s",
1203 response), response)
1204 end
1205
1206 if response.fields.has_key?(OPENID_NS, 'sig')
1207 raise AlreadySigned.new(response)
1208 end
1209
1210 response = @signatory.sign(response)
1211 end
1212
1213 return super(response)
1214 end
1215 end
1216
1217 # I decode an incoming web request in to a OpenIDRequest.
1218 class Decoder
1219
1220 @@handlers = {
1221 'checkid_setup' => CheckIDRequest.method('from_message'),
1222 'checkid_immediate' => CheckIDRequest.method('from_message'),
1223 'check_authentication' => CheckAuthRequest.method('from_message'),
1224 'associate' => AssociateRequest.method('from_message'),
1225 }
1226
1227 attr_accessor :server
1228
1229 # Construct a Decoder. The server is necessary because some
1230 # replies reference their server.
1231 def initialize(server)
1232 @server = server
1233 end
1234
1235 # I transform query parameters into an OpenIDRequest.
1236 #
1237 # If the query does not seem to be an OpenID request at all, I
1238 # return nil.
1239 #
1240 # Raises ProtocolError when the query does not seem to be a valid
1241 # OpenID request.
1242 def decode(query)
1243 if query.nil? or query.length == 0
1244 return nil
1245 end
1246
1247 begin
1248 message = Message.from_post_args(query)
1249 rescue InvalidOpenIDNamespace => e
1250 query = query.dup
1251 query['openid.ns'] = OPENID2_NS
1252 message = Message.from_post_args(query)
1253 raise ProtocolError.new(message, e.to_s)
1254 end
1255
1256 mode = message.get_arg(OPENID_NS, 'mode')
1257 if !mode
1258 msg = sprintf("No mode value in message %s", message)
1259 raise ProtocolError.new(message, msg)
1260 end
1261
1262 handler = @@handlers.fetch(mode, self.method('default_decoder'))
1263 return handler.call(message, @server.op_endpoint)
1264 end
1265
1266 # Called to decode queries when no handler for that mode is
1267 # found.
1268 #
1269 # This implementation always raises ProtocolError.
1270 def default_decoder(message, server)
1271 mode = message.get_arg(OPENID_NS, 'mode')
1272 msg = sprintf("Unrecognized OpenID mode %s", mode)
1273 raise ProtocolError.new(message, msg)
1274 end
1275 end
1276
1277 # I handle requests for an OpenID server.
1278 #
1279 # Some types of requests (those which are not checkid requests)
1280 # may be handed to my handleRequest method, and I will take care
1281 # of it and return a response.
1282 #
1283 # For your convenience, I also provide an interface to
1284 # Decoder.decode and SigningEncoder.encode through my methods
1285 # decodeRequest and encodeResponse.
1286 #
1287 # All my state is encapsulated in an store, which means I'm not
1288 # generally pickleable but I am easy to reconstruct.
1289 class Server
1290 @@signatoryClass = Signatory
1291 @@encoderClass = SigningEncoder
1292 @@decoderClass = Decoder
1293
1294 # The back-end where my associations and nonces are stored.
1295 attr_accessor :store
1296
1297 # I'm using this for associate requests and to sign things.
1298 attr_accessor :signatory
1299
1300 # I'm using this to encode things.
1301 attr_accessor :encoder
1302
1303 # I'm using this to decode things.
1304 attr_accessor :decoder
1305
1306 # I use this instance of OpenID::AssociationNegotiator to
1307 # determine which kinds of associations I can make and how.
1308 attr_accessor :negotiator
1309
1310 # My URL.
1311 attr_accessor :op_endpoint
1312
1313 # op_endpoint is new in library version 2.0.
1314 def initialize(store, op_endpoint)
1315 @store = store
1316 @signatory = @@signatoryClass.new(@store)
1317 @encoder = @@encoderClass.new(@signatory)
1318 @decoder = @@decoderClass.new(self)