Eric Davis f70be197e0 Unpacked OpenID gem. #699
git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@2437 e93f8b46-1217-0410-a6f0-8f06a7374b81
2009-02-11 19:06:37 +00:00

1545 lines
50 KiB
Ruby

require 'openid/cryptutil'
require 'openid/util'
require 'openid/dh'
require 'openid/store/nonce'
require 'openid/trustroot'
require 'openid/association'
require 'openid/message'
require 'time'
module OpenID
module Server
HTTP_OK = 200
HTTP_REDIRECT = 302
HTTP_ERROR = 400
BROWSER_REQUEST_MODES = ['checkid_setup', 'checkid_immediate']
ENCODE_KVFORM = ['kvform'].freeze
ENCODE_URL = ['URL/redirect'].freeze
ENCODE_HTML_FORM = ['HTML form'].freeze
UNUSED = nil
class OpenIDRequest
attr_accessor :message, :mode
# I represent an incoming OpenID request.
#
# Attributes:
# mode:: The "openid.mode" of this request
def initialize
@mode = nil
@message = nil
end
def namespace
if @message.nil?
raise RuntimeError, "Request has no message"
else
return @message.get_openid_namespace
end
end
end
# A request to verify the validity of a previous response.
#
# See OpenID Specs, Verifying Directly with the OpenID Provider
# <http://openid.net/specs/openid-authentication-2_0-12.html#verifying_signatures>
class CheckAuthRequest < OpenIDRequest
# The association handle the response was signed with.
attr_accessor :assoc_handle
# The message with the signature which wants checking.
attr_accessor :signed
# An association handle the client is asking about the validity
# of. May be nil.
attr_accessor :invalidate_handle
attr_accessor :sig
# Construct me.
#
# These parameters are assigned directly as class attributes.
#
# Parameters:
# assoc_handle:: the association handle for this request
# signed:: The signed message
# invalidate_handle:: An association handle that the relying
# party is checking to see if it is invalid
def initialize(assoc_handle, signed, invalidate_handle=nil)
super()
@mode = "check_authentication"
@required_fields = ["identity", "return_to", "response_nonce"].freeze
@sig = nil
@assoc_handle = assoc_handle
@signed = signed
@invalidate_handle = invalidate_handle
end
# Construct me from an OpenID::Message.
def self.from_message(message, op_endpoint=UNUSED)
assoc_handle = message.get_arg(OPENID_NS, 'assoc_handle')
invalidate_handle = message.get_arg(OPENID_NS, 'invalidate_handle')
signed = message.copy()
# openid.mode is currently check_authentication because
# that's the mode of this request. But the signature
# was made on something with a different openid.mode.
# http://article.gmane.org/gmane.comp.web.openid.general/537
if signed.has_key?(OPENID_NS, "mode")
signed.set_arg(OPENID_NS, "mode", "id_res")
end
obj = self.new(assoc_handle, signed, invalidate_handle)
obj.message = message
obj.sig = message.get_arg(OPENID_NS, 'sig')
if !obj.assoc_handle or
!obj.sig
msg = sprintf("%s request missing required parameter from message %s",
obj.mode, message)
raise ProtocolError.new(message, msg)
end
return obj
end
# Respond to this request.
#
# Given a Signatory, I can check the validity of the signature
# and the invalidate_handle. I return a response with an
# is_valid (and, if appropriate invalidate_handle) field.
def answer(signatory)
is_valid = signatory.verify(@assoc_handle, @signed)
# Now invalidate that assoc_handle so it this checkAuth
# message cannot be replayed.
signatory.invalidate(@assoc_handle, dumb=true)
response = OpenIDResponse.new(self)
valid_str = is_valid ? "true" : "false"
response.fields.set_arg(OPENID_NS, 'is_valid', valid_str)
if @invalidate_handle
assoc = signatory.get_association(@invalidate_handle, false)
if !assoc
response.fields.set_arg(
OPENID_NS, 'invalidate_handle', @invalidate_handle)
end
end
return response
end
def to_s
ih = nil
if @invalidate_handle
ih = sprintf(" invalidate? %s", @invalidate_handle)
else
ih = ""
end
s = sprintf("<%s handle: %s sig: %s: signed: %s%s>",
self.class, @assoc_handle,
@sig, @signed, ih)
return s
end
end
class BaseServerSession
attr_reader :session_type
def initialize(session_type, allowed_assoc_types)
@session_type = session_type
@allowed_assoc_types = allowed_assoc_types.dup.freeze
end
def allowed_assoc_type?(typ)
@allowed_assoc_types.member?(typ)
end
end
# An object that knows how to handle association requests with
# no session type.
#
# See OpenID Specs, Section 8: Establishing Associations
# <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
class PlainTextServerSession < BaseServerSession
# The session_type for this association session. There is no
# type defined for plain-text in the OpenID specification, so we
# use 'no-encryption'.
attr_reader :session_type
def initialize
super('no-encryption', ['HMAC-SHA1', 'HMAC-SHA256'])
end
def self.from_message(unused_request)
return self.new
end
def answer(secret)
return {'mac_key' => Util.to_base64(secret)}
end
end
# An object that knows how to handle association requests with the
# Diffie-Hellman session type.
#
# See OpenID Specs, Section 8: Establishing Associations
# <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
class DiffieHellmanSHA1ServerSession < BaseServerSession
# The Diffie-Hellman algorithm values for this request
attr_accessor :dh
# The public key sent by the consumer in the associate request
attr_accessor :consumer_pubkey
# The session_type for this association session.
attr_reader :session_type
def initialize(dh, consumer_pubkey)
super('DH-SHA1', ['HMAC-SHA1'])
@hash_func = CryptUtil.method('sha1')
@dh = dh
@consumer_pubkey = consumer_pubkey
end
# Construct me from OpenID Message
#
# Raises ProtocolError when parameters required to establish the
# session are missing.
def self.from_message(message)
dh_modulus = message.get_arg(OPENID_NS, 'dh_modulus')
dh_gen = message.get_arg(OPENID_NS, 'dh_gen')
if ((!dh_modulus and dh_gen) or
(!dh_gen and dh_modulus))
if !dh_modulus
missing = 'modulus'
else
missing = 'generator'
end
raise ProtocolError.new(message,
sprintf('If non-default modulus or generator is ' +
'supplied, both must be supplied. Missing %s',
missing))
end
if dh_modulus or dh_gen
dh_modulus = CryptUtil.base64_to_num(dh_modulus)
dh_gen = CryptUtil.base64_to_num(dh_gen)
dh = DiffieHellman.new(dh_modulus, dh_gen)
else
dh = DiffieHellman.from_defaults()
end
consumer_pubkey = message.get_arg(OPENID_NS, 'dh_consumer_public')
if !consumer_pubkey
raise ProtocolError.new(message,
sprintf("Public key for DH-SHA1 session " +
"not found in message %s", message))
end
consumer_pubkey = CryptUtil.base64_to_num(consumer_pubkey)
return self.new(dh, consumer_pubkey)
end
def answer(secret)
mac_key = @dh.xor_secret(@hash_func,
@consumer_pubkey,
secret)
return {
'dh_server_public' => CryptUtil.num_to_base64(@dh.public),
'enc_mac_key' => Util.to_base64(mac_key),
}
end
end
class DiffieHellmanSHA256ServerSession < DiffieHellmanSHA1ServerSession
def initialize(*args)
super(*args)
@session_type = 'DH-SHA256'
@hash_func = CryptUtil.method('sha256')
@allowed_assoc_types = ['HMAC-SHA256'].freeze
end
end
# A request to establish an association.
#
# See OpenID Specs, Section 8: Establishing Associations
# <http://openid.net/specs/openid-authentication-2_0-12.html#associations>
class AssociateRequest < OpenIDRequest
# An object that knows how to handle association requests of a
# certain type.
attr_accessor :session
# The type of association. Supported values include HMAC-SHA256
# and HMAC-SHA1
attr_accessor :assoc_type
@@session_classes = {
'no-encryption' => PlainTextServerSession,
'DH-SHA1' => DiffieHellmanSHA1ServerSession,
'DH-SHA256' => DiffieHellmanSHA256ServerSession,
}
# Construct me.
#
# The session is assigned directly as a class attribute. See my
# class documentation for its description.
def initialize(session, assoc_type)
super()
@session = session
@assoc_type = assoc_type
@mode = "associate"
end
# Construct me from an OpenID Message.
def self.from_message(message, op_endpoint=UNUSED)
if message.is_openid1()
session_type = message.get_arg(OPENID_NS, 'session_type')
if session_type == 'no-encryption'
Util.log('Received OpenID 1 request with a no-encryption ' +
'association session type. Continuing anyway.')
elsif !session_type
session_type = 'no-encryption'
end
else
session_type = message.get_arg(OPENID2_NS, 'session_type')
if !session_type
raise ProtocolError.new(message,
text="session_type missing from request")
end
end
session_class = @@session_classes[session_type]
if !session_class
raise ProtocolError.new(message,
sprintf("Unknown session type %s", session_type))
end
begin
session = session_class.from_message(message)
rescue ArgumentError => why
# XXX
raise ProtocolError.new(message,
sprintf('Error parsing %s session: %s',
session_type, why))
end
assoc_type = message.get_arg(OPENID_NS, 'assoc_type', 'HMAC-SHA1')
if !session.allowed_assoc_type?(assoc_type)
msg = sprintf('Session type %s does not support association type %s',
session_type, assoc_type)
raise ProtocolError.new(message, msg)
end
obj = self.new(session, assoc_type)
obj.message = message
return obj
end
# Respond to this request with an association.
#
# assoc:: The association to send back.
#
# Returns a response with the association information, encrypted
# to the consumer's public key if appropriate.
def answer(assoc)
response = OpenIDResponse.new(self)
response.fields.update_args(OPENID_NS, {
'expires_in' => sprintf('%d', assoc.expires_in()),
'assoc_type' => @assoc_type,
'assoc_handle' => assoc.handle,
})
response.fields.update_args(OPENID_NS,
@session.answer(assoc.secret))
unless (@session.session_type == 'no-encryption' and
@message.is_openid1)
response.fields.set_arg(
OPENID_NS, 'session_type', @session.session_type)
end
return response
end
# Respond to this request indicating that the association type
# or association session type is not supported.
def answer_unsupported(message, preferred_association_type=nil,
preferred_session_type=nil)
if @message.is_openid1()
raise ProtocolError.new(@message)
end
response = OpenIDResponse.new(self)
response.fields.set_arg(OPENID_NS, 'error_code', 'unsupported-type')
response.fields.set_arg(OPENID_NS, 'error', message)
if preferred_association_type
response.fields.set_arg(
OPENID_NS, 'assoc_type', preferred_association_type)
end
if preferred_session_type
response.fields.set_arg(
OPENID_NS, 'session_type', preferred_session_type)
end
return response
end
end
# A request to confirm the identity of a user.
#
# This class handles requests for openid modes
# +checkid_immediate+ and +checkid_setup+ .
class CheckIDRequest < OpenIDRequest
# Provided in smart mode requests, a handle for a previously
# established association. nil for dumb mode requests.
attr_accessor :assoc_handle
# Is this an immediate-mode request?
attr_accessor :immediate
# The URL to send the user agent back to to reply to this
# request.
attr_accessor :return_to
# The OP-local identifier being checked.
attr_accessor :identity
# The claimed identifier. Not present in OpenID 1.x
# messages.
attr_accessor :claimed_id
# This URL identifies the party making the request, and the user
# will use that to make her decision about what answer she
# trusts them to have. Referred to as "realm" in OpenID 2.0.
attr_accessor :trust_root
# mode:: +checkid_immediate+ or +checkid_setup+
attr_accessor :mode
attr_accessor :op_endpoint
# These parameters are assigned directly as attributes,
# see the #CheckIDRequest class documentation for their
# descriptions.
#
# Raises #MalformedReturnURL when the +return_to+ URL is not
# a URL.
def initialize(identity, return_to, op_endpoint, trust_root=nil,
immediate=false, assoc_handle=nil, claimed_id=nil)
@assoc_handle = assoc_handle
@identity = identity
@claimed_id = (claimed_id or identity)
@return_to = return_to
@trust_root = (trust_root or return_to)
@op_endpoint = op_endpoint
@message = nil
if immediate
@immediate = true
@mode = "checkid_immediate"
else
@immediate = false
@mode = "checkid_setup"
end
if @return_to and
!TrustRoot::TrustRoot.parse(@return_to)
raise MalformedReturnURL.new(nil, @return_to)
end
if !trust_root_valid()
raise UntrustedReturnURL.new(nil, @return_to, @trust_root)
end
end
# Construct me from an OpenID message.
#
# message:: An OpenID checkid_* request Message
#
# op_endpoint:: The endpoint URL of the server that this
# message was sent to.
#
# Raises:
# ProtocolError:: When not all required parameters are present
# in the message.
#
# MalformedReturnURL:: When the +return_to+ URL is not a URL.
#
# UntrustedReturnURL:: When the +return_to+ URL is
# outside the +trust_root+.
def self.from_message(message, op_endpoint)
obj = self.allocate
obj.message = message
obj.op_endpoint = op_endpoint
mode = message.get_arg(OPENID_NS, 'mode')
if mode == "checkid_immediate"
obj.immediate = true
obj.mode = "checkid_immediate"
else
obj.immediate = false
obj.mode = "checkid_setup"
end
obj.return_to = message.get_arg(OPENID_NS, 'return_to')
if message.is_openid1 and !obj.return_to
msg = sprintf("Missing required field 'return_to' from %s",
message)
raise ProtocolError.new(message, msg)
end
obj.identity = message.get_arg(OPENID_NS, 'identity')
obj.claimed_id = message.get_arg(OPENID_NS, 'claimed_id')
if message.is_openid1()
if !obj.identity
s = "OpenID 1 message did not contain openid.identity"
raise ProtocolError.new(message, s)
end
else
if obj.identity and not obj.claimed_id
s = ("OpenID 2.0 message contained openid.identity but not " +
"claimed_id")
raise ProtocolError.new(message, s)
elsif obj.claimed_id and not obj.identity
s = ("OpenID 2.0 message contained openid.claimed_id but not " +
"identity")
raise ProtocolError.new(message, s)
end
end
# There's a case for making self.trust_root be a TrustRoot
# here. But if TrustRoot isn't currently part of the "public"
# API, I'm not sure it's worth doing.
if message.is_openid1
trust_root_param = 'trust_root'
else
trust_root_param = 'realm'
end
trust_root = message.get_arg(OPENID_NS, trust_root_param)
trust_root = obj.return_to if (trust_root.nil? || trust_root.empty?)
obj.trust_root = trust_root
if !message.is_openid1 and !obj.return_to and !obj.trust_root
raise ProtocolError.new(message, "openid.realm required when " +
"openid.return_to absent")
end
obj.assoc_handle = message.get_arg(OPENID_NS, 'assoc_handle')
# Using TrustRoot.parse here is a bit misleading, as we're not
# parsing return_to as a trust root at all. However, valid
# URLs are valid trust roots, so we can use this to get an
# idea if it is a valid URL. Not all trust roots are valid
# return_to URLs, however (particularly ones with wildcards),
# so this is still a little sketchy.
if obj.return_to and \
!TrustRoot::TrustRoot.parse(obj.return_to)
raise MalformedReturnURL.new(message, obj.return_to)
end
# I first thought that checking to see if the return_to is
# within the trust_root is premature here, a
# logic-not-decoding thing. But it was argued that this is
# really part of data validation. A request with an invalid
# trust_root/return_to is broken regardless of application,
# right?
if !obj.trust_root_valid()
raise UntrustedReturnURL.new(message, obj.return_to, obj.trust_root)
end
return obj
end
# Is the identifier to be selected by the IDP?
def id_select
# So IDPs don't have to import the constant
return @identity == IDENTIFIER_SELECT
end
# Is my return_to under my trust_root?
def trust_root_valid
if !@trust_root
return true
end
tr = TrustRoot::TrustRoot.parse(@trust_root)
if !tr
raise MalformedTrustRoot.new(@message, @trust_root)
end
if @return_to
return tr.validate_url(@return_to)
else
return true
end
end
# Does the relying party publish the return_to URL for this
# response under the realm? It is up to the provider to set a
# policy for what kinds of realms should be allowed. This
# return_to URL verification reduces vulnerability to
# data-theft attacks based on open proxies,
# corss-site-scripting, or open redirectors.
#
# This check should only be performed after making sure that
# the return_to URL matches the realm.
#
# Raises DiscoveryFailure if the realm
# URL does not support Yadis discovery (and so does not
# support the verification process).
#
# Returns true if the realm publishes a document with the
# return_to URL listed
def return_to_verified
return TrustRoot.verify_return_to(@trust_root, @return_to)
end
# Respond to this request.
#
# allow:: Allow this user to claim this identity, and allow the
# consumer to have this information?
#
# server_url:: DEPRECATED. Passing op_endpoint to the
# #Server constructor makes this optional.
#
# When an OpenID 1.x immediate mode request does
# not succeed, it gets back a URL where the request
# may be carried out in a not-so-immediate fashion.
# Pass my URL in here (the fully qualified address
# of this server's endpoint, i.e.
# <tt>http://example.com/server</tt>), and I will
# use it as a base for the URL for a new request.
#
# Optional for requests where
# #CheckIDRequest.immediate is false or +allow+ is
# true.
#
# identity:: The OP-local identifier to answer with. Only for use
# when the relying party requested identifier selection.
#
# claimed_id:: The claimed identifier to answer with,
# for use with identifier selection in the case where the
# claimed identifier and the OP-local identifier differ,
# i.e. when the claimed_id uses delegation.
#
# If +identity+ is provided but this is not,
# +claimed_id+ will default to the value of +identity+.
# When answering requests that did not ask for identifier
# selection, the response +claimed_id+ will default to
# that of the request.
#
# This parameter is new in OpenID 2.0.
#
# Returns an OpenIDResponse object containing a OpenID id_res message.
#
# Raises NoReturnToError if the return_to is missing.
#
# Version 2.0 deprecates +server_url+ and adds +claimed_id+.
def answer(allow, server_url=nil, identity=nil, claimed_id=nil)
if !@return_to
raise NoReturnToError
end
if !server_url
if @message.is_openid2 and !@op_endpoint
# In other words, that warning I raised in
# Server.__init__? You should pay attention to it now.
raise RuntimeError, ("#{self} should be constructed with "\
"op_endpoint to respond to OpenID 2.0 "\
"messages.")
end
server_url = @op_endpoint
end
if allow
mode = 'id_res'
elsif @message.is_openid1
if @immediate
mode = 'id_res'
else
mode = 'cancel'
end
else
if @immediate
mode = 'setup_needed'
else
mode = 'cancel'
end
end
response = OpenIDResponse.new(self)
if claimed_id and @message.is_openid1
raise VersionError, ("claimed_id is new in OpenID 2.0 and not "\
"available for #{@message.get_openid_namespace}")
end
if identity and !claimed_id
claimed_id = identity
end
if allow
if @identity == IDENTIFIER_SELECT
if !identity
raise ArgumentError, ("This request uses IdP-driven "\
"identifier selection.You must supply "\
"an identifier in the response.")
end
response_identity = identity
response_claimed_id = claimed_id
elsif @identity
if identity and (@identity != identity)
raise ArgumentError, ("Request was for identity #{@identity}, "\
"cannot reply with identity #{identity}")
end
response_identity = @identity
response_claimed_id = @claimed_id
else
if identity
raise ArgumentError, ("This request specified no identity "\
"and you supplied #{identity}")
end
response_identity = nil
end
if @message.is_openid1 and !response_identity
raise ArgumentError, ("Request was an OpenID 1 request, so "\
"response must include an identifier.")
end
response.fields.update_args(OPENID_NS, {
'mode' => mode,
'op_endpoint' => server_url,
'return_to' => @return_to,
'response_nonce' => Nonce.mk_nonce(),
})
if response_identity
response.fields.set_arg(OPENID_NS, 'identity', response_identity)
if @message.is_openid2
response.fields.set_arg(OPENID_NS,
'claimed_id', response_claimed_id)
end
end
else
response.fields.set_arg(OPENID_NS, 'mode', mode)
if @immediate
if @message.is_openid1 and !server_url
raise ArgumentError, ("setup_url is required for allow=false "\
"in OpenID 1.x immediate mode.")
end
# Make a new request just like me, but with
# immediate=false.
setup_request = self.class.new(@identity, @return_to,
@op_endpoint, @trust_root, false,
@assoc_handle, @claimed_id)
setup_request.message = Message.new(@message.get_openid_namespace)
setup_url = setup_request.encode_to_url(server_url)
response.fields.set_arg(OPENID_NS, 'user_setup_url', setup_url)
end
end
return response
end
def encode_to_url(server_url)
# Encode this request as a URL to GET.
#
# server_url:: The URL of the OpenID server to make this
# request of.
if !@return_to
raise NoReturnToError
end
# Imported from the alternate reality where these classes are
# used in both the client and server code, so Requests are
# Encodable too. That's right, code imported from alternate
# realities all for the love of you, id_res/user_setup_url.
q = {'mode' => @mode,
'identity' => @identity,
'claimed_id' => @claimed_id,
'return_to' => @return_to}
if @trust_root
if @message.is_openid1
q['trust_root'] = @trust_root
else
q['realm'] = @trust_root
end
end
if @assoc_handle
q['assoc_handle'] = @assoc_handle
end
response = Message.new(@message.get_openid_namespace)
response.update_args(@message.get_openid_namespace, q)
return response.to_url(server_url)
end
def cancel_url
# Get the URL to cancel this request.
#
# Useful for creating a "Cancel" button on a web form so that
# operation can be carried out directly without another trip
# through the server.
#
# (Except you may want to make another trip through the
# server so that it knows that the user did make a decision.)
#
# Returns a URL as a string.
if !@return_to
raise NoReturnToError
end
if @immediate
raise ArgumentError.new("Cancel is not an appropriate response to " +
"immediate mode requests.")
end
response = Message.new(@message.get_openid_namespace)
response.set_arg(OPENID_NS, 'mode', 'cancel')
return response.to_url(@return_to)
end
def to_s
return sprintf('<%s id:%s im:%s tr:%s ah:%s>', self.class,
@identity,
@immediate,
@trust_root,
@assoc_handle)
end
end
# I am a response to an OpenID request.
#
# Attributes:
# signed:: A list of the names of the fields which should be signed.
#
# Implementer's note: In a more symmetric client/server
# implementation, there would be more types of #OpenIDResponse
# object and they would have validated attributes according to
# the type of response. But as it is, Response objects in a
# server are basically write-only, their only job is to go out
# over the wire, so this is just a loose wrapper around
# #OpenIDResponse.fields.
class OpenIDResponse
# The #OpenIDRequest I respond to.
attr_accessor :request
# An #OpenID::Message with the data to be returned.
# Keys are parameter names with no
# leading openid. e.g. identity and mac_key
# never openid.identity.
attr_accessor :fields
def initialize(request)
# Make a response to an OpenIDRequest.
@request = request
@fields = Message.new(request.namespace)
end
def to_s
return sprintf("%s for %s: %s",
self.class,
@request.class,
@fields)
end
# form_tag_attrs is a hash of attributes to be added to the form
# tag. 'accept-charset' and 'enctype' have defaults that can be
# overridden. If a value is supplied for 'action' or 'method',
# it will be replaced.
# Returns the form markup for this response.
def to_form_markup(form_tag_attrs=nil)
return @fields.to_form_markup(@request.return_to, form_tag_attrs)
end
# Wraps the form tag from to_form_markup in a complete HTML document
# that uses javascript to autosubmit the form.
def to_html(form_tag_attrs=nil)
return Util.auto_submit_html(to_form_markup(form_tag_attrs))
end
def render_as_form
# Returns true if this response's encoding is
# ENCODE_HTML_FORM. Convenience method for server authors.
return self.which_encoding == ENCODE_HTML_FORM
end
def needs_signing
# Does this response require signing?
return @fields.get_arg(OPENID_NS, 'mode') == 'id_res'
end
# implements IEncodable
def which_encoding
# How should I be encoded?
# returns one of ENCODE_URL or ENCODE_KVFORM.
if BROWSER_REQUEST_MODES.member?(@request.mode)
if @fields.is_openid2 and
encode_to_url.length > OPENID1_URL_LIMIT
return ENCODE_HTML_FORM
else
return ENCODE_URL
end
else
return ENCODE_KVFORM
end
end
def encode_to_url
# Encode a response as a URL for the user agent to GET.
# You will generally use this URL with a HTTP redirect.
return @fields.to_url(@request.return_to)
end
def add_extension(extension_response)
# Add an extension response to this response message.
#
# extension_response:: An object that implements the
# #OpenID::Extension interface for adding arguments to an OpenID
# message.
extension_response.to_message(@fields)
end
def encode_to_kvform
# Encode a response in key-value colon/newline format.
#
# This is a machine-readable format used to respond to
# messages which came directly from the consumer and not
# through the user agent.
#
# see: OpenID Specs,
# <a href="http://openid.net/specs.bml#keyvalue">Key-Value Colon/Newline format</a>
return @fields.to_kvform
end
def copy
return Marshal.load(Marshal.dump(self))
end
end
# I am a response to an OpenID request in terms a web server
# understands.
#
# I generally come from an #Encoder, either directly or from
# #Server.encodeResponse.
class WebResponse
# The HTTP code of this response as an integer.
attr_accessor :code
# #Hash of headers to include in this response.
attr_accessor :headers
# The body of this response.
attr_accessor :body
def initialize(code=HTTP_OK, headers=nil, body="")
# Construct me.
#
# These parameters are assigned directly as class attributes,
# see my class documentation for their
# descriptions.
@code = code
if headers
@headers = headers
else
@headers = {}
end
@body = body
end
end
# I sign things.
#
# I also check signatures.
#
# All my state is encapsulated in a store, which means I'm not
# generally pickleable but I am easy to reconstruct.
class Signatory
# The number of seconds a secret remains valid. Defaults to 14 days.
attr_accessor :secret_lifetime
# keys have a bogus server URL in them because the filestore
# really does expect that key to be a URL. This seems a little
# silly for the server store, since I expect there to be only
# one server URL.
@@_normal_key = 'http://localhost/|normal'
@@_dumb_key = 'http://localhost/|dumb'
def self._normal_key
@@_normal_key
end
def self._dumb_key
@@_dumb_key
end
attr_accessor :store
# Create a new Signatory. store is The back-end where my
# associations are stored.
def initialize(store)
Util.assert(store)
@store = store
@secret_lifetime = 14 * 24 * 60 * 60
end
# Verify that the signature for some data is valid.
def verify(assoc_handle, message)
assoc = get_association(assoc_handle, true)
if !assoc
Util.log(sprintf("failed to get assoc with handle %s to verify " +
"message %s", assoc_handle, message))
return false
end
begin
valid = assoc.check_message_signature(message)
rescue StandardError => ex
Util.log(sprintf("Error in verifying %s with %s: %s",
message, assoc, ex))
return false
end
return valid
end
# Sign a response.
#
# I take an OpenIDResponse, create a signature for everything in
# its signed list, and return a new copy of the response object
# with that signature included.
def sign(response)
signed_response = response.copy
assoc_handle = response.request.assoc_handle
if assoc_handle
# normal mode disabling expiration check because even if the
# association is expired, we still need to know some
# properties of the association so that we may preserve
# those properties when creating the fallback association.
assoc = get_association(assoc_handle, false, false)
if !assoc or assoc.expires_in <= 0
# fall back to dumb mode
signed_response.fields.set_arg(
OPENID_NS, 'invalidate_handle', assoc_handle)
assoc_type = assoc ? assoc.assoc_type : 'HMAC-SHA1'
if assoc and assoc.expires_in <= 0
# now do the clean-up that the disabled checkExpiration
# code didn't get to do.
invalidate(assoc_handle, false)
end
assoc = create_association(true, assoc_type)
end
else
# dumb mode.
assoc = create_association(true)
end
begin
signed_response.fields = assoc.sign_message(signed_response.fields)
rescue KVFormError => err
raise EncodingError, err
end
return signed_response
end
# Make a new association.
def create_association(dumb=true, assoc_type='HMAC-SHA1')
secret = CryptUtil.random_string(OpenID.get_secret_size(assoc_type))
uniq = Util.to_base64(CryptUtil.random_string(4))
handle = sprintf('{%s}{%x}{%s}', assoc_type, Time.now.to_i, uniq)
assoc = Association.from_expires_in(
secret_lifetime, handle, secret, assoc_type)
if dumb
key = @@_dumb_key
else
key = @@_normal_key
end
@store.store_association(key, assoc)
return assoc
end
# Get the association with the specified handle.
def get_association(assoc_handle, dumb, checkExpiration=true)
# Hmm. We've created an interface that deals almost entirely
# with assoc_handles. The only place outside the Signatory
# that uses this (and thus the only place that ever sees
# Association objects) is when creating a response to an
# association request, as it must have the association's
# secret.
if !assoc_handle
raise ArgumentError.new("assoc_handle must not be None")
end
if dumb
key = @@_dumb_key
else
key = @@_normal_key
end
assoc = @store.get_association(key, assoc_handle)
if assoc and assoc.expires_in <= 0
Util.log(sprintf("requested %sdumb key %s is expired (by %s seconds)",
(!dumb) ? 'not-' : '',
assoc_handle, assoc.expires_in))
if checkExpiration
@store.remove_association(key, assoc_handle)
assoc = nil
end
end
return assoc
end
# Invalidates the association with the given handle.
def invalidate(assoc_handle, dumb)
if dumb
key = @@_dumb_key
else
key = @@_normal_key
end
@store.remove_association(key, assoc_handle)
end
end
# I encode responses in to WebResponses.
#
# If you don't like WebResponses, you can do
# your own handling of OpenIDResponses with
# OpenIDResponse.whichEncoding,
# OpenIDResponse.encodeToURL, and
# OpenIDResponse.encodeToKVForm.
class Encoder
@@responseFactory = WebResponse
# Encode a response to a WebResponse.
#
# Raises EncodingError when I can't figure out how to encode
# this message.
def encode(response)
encode_as = response.which_encoding()
if encode_as == ENCODE_KVFORM
wr = @@responseFactory.new(HTTP_OK, nil,
response.encode_to_kvform())
if response.is_a?(Exception)
wr.code = HTTP_ERROR
end
elsif encode_as == ENCODE_URL
location = response.encode_to_url()
wr = @@responseFactory.new(HTTP_REDIRECT,
{'location' => location})
elsif encode_as == ENCODE_HTML_FORM
wr = @@responseFactory.new(HTTP_OK, nil,
response.to_form_markup())
else
# Can't encode this to a protocol message. You should
# probably render it to HTML and show it to the user.
raise EncodingError.new(response)
end
return wr
end
end
# I encode responses in to WebResponses, signing
# them when required.
class SigningEncoder < Encoder
attr_accessor :signatory
# Create a SigningEncoder given a Signatory
def initialize(signatory)
@signatory = signatory
end
# Encode a response to a WebResponse, signing it first if
# appropriate.
#
# Raises EncodingError when I can't figure out how to encode this
# message.
#
# Raises AlreadySigned when this response is already signed.
def encode(response)
# the is_a? is a bit of a kludge... it means there isn't
# really an adapter to make the interfaces quite match.
if !response.is_a?(Exception) and response.needs_signing()
if !@signatory
raise ArgumentError.new(
sprintf("Must have a store to sign this request: %s",
response), response)
end
if response.fields.has_key?(OPENID_NS, 'sig')
raise AlreadySigned.new(response)
end
response = @signatory.sign(response)
end
return super(response)
end
end
# I decode an incoming web request in to a OpenIDRequest.
class Decoder
@@handlers = {
'checkid_setup' => CheckIDRequest.method('from_message'),
'checkid_immediate' => CheckIDRequest.method('from_message'),
'check_authentication' => CheckAuthRequest.method('from_message'),
'associate' => AssociateRequest.method('from_message'),
}
attr_accessor :server
# Construct a Decoder. The server is necessary because some
# replies reference their server.
def initialize(server)
@server = server
end
# I transform query parameters into an OpenIDRequest.
#
# If the query does not seem to be an OpenID request at all, I
# return nil.
#
# Raises ProtocolError when the query does not seem to be a valid
# OpenID request.
def decode(query)
if query.nil? or query.length == 0
return nil
end
begin
message = Message.from_post_args(query)
rescue InvalidOpenIDNamespace => e
query = query.dup
query['openid.ns'] = OPENID2_NS
message = Message.from_post_args(query)
raise ProtocolError.new(message, e.to_s)
end
mode = message.get_arg(OPENID_NS, 'mode')
if !mode
msg = sprintf("No mode value in message %s", message)
raise ProtocolError.new(message, msg)
end
handler = @@handlers.fetch(mode, self.method('default_decoder'))
return handler.call(message, @server.op_endpoint)
end
# Called to decode queries when no handler for that mode is
# found.
#
# This implementation always raises ProtocolError.
def default_decoder(message, server)
mode = message.get_arg(OPENID_NS, 'mode')
msg = sprintf("Unrecognized OpenID mode %s", mode)
raise ProtocolError.new(message, msg)
end
end
# I handle requests for an OpenID server.
#
# Some types of requests (those which are not checkid requests)
# may be handed to my handleRequest method, and I will take care
# of it and return a response.
#
# For your convenience, I also provide an interface to
# Decoder.decode and SigningEncoder.encode through my methods
# decodeRequest and encodeResponse.
#
# All my state is encapsulated in an store, which means I'm not
# generally pickleable but I am easy to reconstruct.
class Server
@@signatoryClass = Signatory
@@encoderClass = SigningEncoder
@@decoderClass = Decoder
# The back-end where my associations and nonces are stored.
attr_accessor :store
# I'm using this for associate requests and to sign things.
attr_accessor :signatory
# I'm using this to encode things.
attr_accessor :encoder
# I'm using this to decode things.
attr_accessor :decoder
# I use this instance of OpenID::AssociationNegotiator to
# determine which kinds of associations I can make and how.
attr_accessor :negotiator
# My URL.
attr_accessor :op_endpoint
# op_endpoint is new in library version 2.0.
def initialize(store, op_endpoint)
@store = store
@signatory = @@signatoryClass.new(@store)
@encoder = @@encoderClass.new(@signatory)
@decoder = @@decoderClass.new(self)
@negotiator = DefaultNegotiator.copy()
@op_endpoint = op_endpoint
end
# Handle a request.
#
# Give me a request, I will give you a response. Unless it's a
# type of request I cannot handle myself, in which case I will
# raise RuntimeError. In that case, you can handle it yourself,
# or add a method to me for handling that request type.
def handle_request(request)
begin
handler = self.method('openid_' + request.mode)
rescue NameError
raise RuntimeError.new(
sprintf("%s has no handler for a request of mode %s.",
self, request.mode))
end
return handler.call(request)
end
# Handle and respond to check_authentication requests.
def openid_check_authentication(request)
return request.answer(@signatory)
end
# Handle and respond to associate requests.
def openid_associate(request)
assoc_type = request.assoc_type
session_type = request.session.session_type
if @negotiator.allowed?(assoc_type, session_type)
assoc = @signatory.create_association(false,
assoc_type)
return request.answer(assoc)
else
message = sprintf('Association type %s is not supported with ' +
'session type %s', assoc_type, session_type)
preferred_assoc_type, preferred_session_type = @negotiator.get_allowed_type()
return request.answer_unsupported(message,
preferred_assoc_type,
preferred_session_type)
end
end
# Transform query parameters into an OpenIDRequest.
# query should contain the query parameters as a Hash with
# each key mapping to one value.
#
# If the query does not seem to be an OpenID request at all, I
# return nil.
def decode_request(query)
return @decoder.decode(query)
end
# Encode a response to a WebResponse, signing it first if
# appropriate.
#
# Raises EncodingError when I can't figure out how to encode this
# message.
#
# Raises AlreadySigned When this response is already signed.
def encode_response(response)
return @encoder.encode(response)
end
end
# A message did not conform to the OpenID protocol.
class ProtocolError < Exception
# The query that is failing to be a valid OpenID request.
attr_accessor :openid_message
attr_accessor :reference
attr_accessor :contact
# text:: A message about the encountered error.
def initialize(message, text=nil, reference=nil, contact=nil)
@openid_message = message
@reference = reference
@contact = contact
Util.assert(!message.is_a?(String))
super(text)
end
# Get the return_to argument from the request, if any.
def get_return_to
if @openid_message.nil?
return nil
else
return @openid_message.get_arg(OPENID_NS, 'return_to')
end
end
# Did this request have a return_to parameter?
def has_return_to
return !get_return_to.nil?
end
# Generate a Message object for sending to the relying party,
# after encoding.
def to_message
namespace = @openid_message.get_openid_namespace()
reply = Message.new(namespace)
reply.set_arg(OPENID_NS, 'mode', 'error')
reply.set_arg(OPENID_NS, 'error', self.to_s)
if @contact
reply.set_arg(OPENID_NS, 'contact', @contact.to_s)
end
if @reference
reply.set_arg(OPENID_NS, 'reference', @reference.to_s)
end
return reply
end
# implements IEncodable
def encode_to_url
return to_message().to_url(get_return_to())
end
def encode_to_kvform
return to_message().to_kvform()
end
def to_form_markup
return to_message().to_form_markup(get_return_to())
end
def to_html
return Util.auto_submit_html(to_form_markup)
end
# How should I be encoded?
#
# Returns one of ENCODE_URL, ENCODE_KVFORM, or None. If None,
# I cannot be encoded as a protocol message and should be
# displayed to the user.
def which_encoding
if has_return_to()
if @openid_message.is_openid2 and
encode_to_url().length > OPENID1_URL_LIMIT
return ENCODE_HTML_FORM
else
return ENCODE_URL
end
end
if @openid_message.nil?
return nil
end
mode = @openid_message.get_arg(OPENID_NS, 'mode')
if mode
if !BROWSER_REQUEST_MODES.member?(mode)
return ENCODE_KVFORM
end
end
# If your request was so broken that you didn't manage to
# include an openid.mode, I'm not going to worry too much
# about returning you something you can't parse.
return nil
end
end
# Raised when an operation was attempted that is not compatible
# with the protocol version being used.
class VersionError < Exception
end
# Raised when a response to a request cannot be generated
# because the request contains no return_to URL.
class NoReturnToError < Exception
end
# Could not encode this as a protocol message.
#
# You should probably render it and show it to the user.
class EncodingError < Exception
# The response that failed to encode.
attr_reader :response
def initialize(response)
super(response)
@response = response
end
end
# This response is already signed.
class AlreadySigned < EncodingError
end
# A return_to is outside the trust_root.
class UntrustedReturnURL < ProtocolError
attr_reader :return_to, :trust_root
def initialize(message, return_to, trust_root)
super(message)
@return_to = return_to
@trust_root = trust_root
end
def to_s
return sprintf("return_to %s not under trust_root %s",
@return_to,
@trust_root)
end
end
# The return_to URL doesn't look like a valid URL.
class MalformedReturnURL < ProtocolError
attr_reader :return_to
def initialize(openid_message, return_to)
@return_to = return_to
super(openid_message)
end
end
# The trust root is not well-formed.
class MalformedTrustRoot < ProtocolError
end
end
end