require 'crypto_methods'
require 'html/htmltokenizer'

class OpenidController < ApplicationController
  @@SIGNED_FIELDS = %w(mode identity return_to)

  ###################################################################### 
  #                     Authentication Scaffolding                     #
  ###################################################################### 

  def login
    if params[:cancel]
      redirect_to params[:fail_to] if params[:fail_to]
    elsif params[:user]
      cookies['user'] = params[:user]
      redirect_to params[:success_to] if params[:success_to]
    end
  end

  def identity
    @user = params[:user]
  end

  def whoami
    @user = cookies[:user]
  end

  def loggedin
    @user = cookies[:user]
  end

  def decide
    ### TODO: TrustRoot verification

    @user = cookies[:user]
    @identity = openid.identity
    @trust_root = openid.trust_root
    @success_to = openid.success_to
    @fail_to = openid.fail_to

    if not @user or not owns?(openid.identity!)

      redirect_to :action=>'login',
        'success_to'=>@success_to, 'fail_to'=>@fail_to
   
    elsif request.post?

      if params['yes'] == 'yes'
        allow = Allow.new
        allow.user = @user
        allow.trust_root = @trust_root
        allow.save!

        redirect_to @success_to if @success_to and not @success_to.empty?
      else
        redirect_to @fail_to if @fail_to and not @fail_to.empty?
      end

      redirect_to :action=>'main' unless performed?

    elsif not @trust_root

      render :file => "#{RAILS_ROOT}/public/404.html",
        :status => '404 Not Found'

    end
  end

  ###################################################################### 
  #                          OpenId Consumer                           #
  ###################################################################### 

  def consumer

    case openid.mode
    when nil:
      return if request.get?

      return flash[:notice]='Please enter an Identity URL' if 
        params['identity'].nil? or params['identity'].empty?

      server, delegate = autodiscover params['identity']

      return flash[:notice]='No openid.server found for URI' unless server
      delegate ||= params['identity']

      assoc = Assoc.find :first, :conditions=>['identity=?',params['identity']],
        :order=>'issued + interval lifetime second desc'

      if assoc
        assoc.destroy if assoc.expires_in <= 0
        assoc = nil   if assoc.expires_in < 30
      end

      if not assoc
        dh = DiffieHellman.new

        args = {
          'openid.mode'=>'associate',
          'openid.assoc_type'=>'HMAC-SHA1',
          'openid.session_type'=>'DH-SHA1',
          'openid.dh_consumer_public'=>dh.createKeyExchange.btwoc.base64
        }

        response = URI.parse(server).post(args)

        return render(:text=>response.body,
          :status=>"#{response.code} #{response.message}") unless
          response.code.to_i == 200 and response.body[0] != '<'

        response = response.body.parsekv

        assoc = Assoc.new
        assoc.identity = params['identity']
        assoc.handle   = response['assoc_handle']
        assoc.lifetime = response['expires_in'].to_i

        if response['dh_server_public']
          server_public  = response['dh_server_public']
          dh_shared =  dh.decryptKeyExchange(server_public.unbase64.unbtwoc)
          assoc.secret = response['enc_mac_key'].unbase64^dh_shared.btwoc.sha1
        else
          assoc.secret = response['mac_key'].unbase64
        end

        assoc.save!

      end

      args = {
        'openid.mode'=>'checkid_setup',
        'openid.identity'=>delegate,
        'openid.trust_root'=>url_for(:action=>'consumer', :only_path=>false),
        'openid.return_to'=>url_for(:action=>'consumer', :only_path=>false),
      }

      args['openid.assoc_handle']=assoc.handle if assoc

      redirect_to URI.parse(server).add_params(args).to_s

    when 'cancel':

      flash[:notice] = 'Cancelled by user'

    when 'id_res':

      if not openid.invalidate_handle

        assoc = Assoc.find :first, :conditions=>['handle=? and identity=?',
          openid.assoc_handle!, openid.identity!]

        return flash[:notice] = 'Unrecognized assoc_handle' if not assoc

        params['openid.mode'] = 'id_res'
        if params.sign(assoc.secret, openid.signed!.split(',')) == openid.sig!
          flash[:notice] = 'Identity verified'
        else
          flash[:notice] = 'Signature not valid'
        end

      else

        params['openid.mode'] = 'check_authentication'
        server, delegate = autodiscover openid.identity!

        response = URI.parse(server).post params

        return render(:text=>response.body,
          :status=>"#{response.code} #{response.message}") unless
          response.code.to_i == 200 and response.body[0] != '<'

        if response.body.parsekv['is_valid'] == 'true'
          flash[:notice] = 'Identity verified'
        else
          flash[:notice] = 'Identity NOT verified'
        end

        Assoc.find :all, :conditions=> ['handle=?', openid.invalidate_handle] do
          |object| object.destroy
        end

      end

    else

      flash[:notice] = openid.mode + ' not implemented'

    end

  rescue BadRequest => error
    flash[:notice] = error.field + ' is missing or invalid'
  rescue Exception => exception
    return flash[:notice]=CGI.escapeHTML(exception.to_s)
  end

  ###################################################################### 
  #                           OpenId Server                            #
  ###################################################################### 

  def server
    bad_request :mode unless respond_to? "server_" + openid.mode!

    send "server_" + openid.mode

  rescue BadRequest => error
    @field = error.field
    render(:file => "#{RAILS_ROOT}/public/400.html",
      :status => '400 Bad Request')
  end

  # http://openid.net/specs.bml#mode-associate

  def server_associate
    openid.assoc_type ||= 'HMAC-SHA1'
    bad_request :assoc_type unless openid.assoc_type=='HMAC-SHA1'

    assoc = Assoc.new
    assoc.identity = openid.identity

    if openid.session_type == 'DH-SHA1'
      dh = DiffieHellman.new
      dh.p = openid.dh_modulus.unbase64.unbtwoc if openid.dh_modulus
      dh.g = openid.dh_gen.unbase64.unbtwoc     if openid.dh_gen
      dh_shared = dh.decryptKeyExchange openid.dh_consumer_public!.unbase64.unbtwoc

      reply = {
        :session_type => openid.session_type!,
        :dh_server_public => dh.createKeyExchange.btwoc.base64,
        :enc_mac_key => (assoc.secret ^ dh_shared.btwoc.sha1).base64
      }
    elsif [nil,''].include? openid.session_type
      reply = { :mac_key => assoc.secret.base64 }
    else
      bad_request :session_type
    end

    reply.update(
      :assoc_type => openid.assoc_type,
      :assoc_handle => assoc.handle,
      :expires_in => assoc.lifetime
    )

    assoc.save!
    render :text => reply.to_kv
  end

  # http://openid.net/specs.bml#mode-checkid_setup

  def server_checkid_setup

    if check_id?

      id_response

    else

      redirect_to :action=>'decide', 
        'openid.identity'=>openid.identity!,
        'openid.success_to'=>url_for(params),
        'openid.fail_to'=>
          URI.parse(openid.return_to!).add_params('openid.mode'=>'cancel').to_s,
        'openid.trust_root'=>openid.trust_root!

    end

  end

  # http://openid.net/specs.bml#mode-checkid_immediate

  def server_checkid_immediate

    if check_id?

      id_response

    else

      args = {
        'openid.mode' => 'checkid_setup',
        'openid.identity' => openid.identity!,
        'openid.trust_root' => openid.trust_root!,
        'openid.return_to' => openid.return_to!,
      }

      args['openid.assoc_handle'] = openid.assoc_handle if openid.assoc_handle

      reply = { 
        'openid.mode' => 'id_res',
        'openid.user_setup_url' => 
          url_for(:server, args.update(:only_path=>false))
      }

      redirect_to URI.parse(openid.return_to).add_params(reply).to_s

    end

  end

  # http://openid.net/specs.bml#mode-check_authentication

  def server_check_authentication
    assoc = Assoc.find :first, :conditions=>['handle=?',openid.assoc_handle!]

    reply = {'is_valid'=>'false'}

    if assoc
      if assoc.expires_in <= 0
        reply.update('openid.invalidate_handle'=>assoc.handle)
        assoc.destroy
      else
        params['openid.mode'] = 'id_res'
        if params.sign(assoc.secret, openid.signed!.split(',')) == openid.sig!
          reply = {'is_valid'=>'true'}
        end
      end
    end

    render :text => reply.to_kv
  end

  # raise bad request exception, specifying a field
  def bad_request field
    bad_request 'openid.'+field.to_s if field.is_a? Symbol
    raise BadRequest.new(field.sub(/[?=!]$/, ''))
  end

private

  ###################################################################### 
  #                          Utility Methods                           #
  ###################################################################### 

  # logged in, and owns identity, and trusts the provided root
  def check_id?
    (cookies[:user]) and owns?(openid.identity!) and
    (Allow.find(:first, :conditions=>
      ['user=? and trust_root=?',cookies[:user], openid.trust_root]))
  end

  # current logged in user "owns" the specified identity URI?
  def owns? url
    url == url_for(:action=>'identity',
      :user=>cookies[:user], :only_path=>false)
  end

  # discover server (and optionally, delegate) to use to validate identity
  def autodiscover identity
    tokenizer = HTMLTokenizer.new URI.parse(identity).get.body

    while link = tokenizer.getTag('link')
      server ||= link.attr_hash['href'] if
        link.attr_hash['rel'] == 'openid.server'
      delegate ||= link.attr_hash['href'] if
        link.attr_hash['rel'] == 'openid.delegate'
    end

    return server, delegate
  end

  # validated identity in the form of a response
  def id_response
    reply = {
      'openid.mode' => 'id_res',
      'openid.return_to' => openid.return_to!,
      'openid.identity' => openid.identity!,
    }

    if openid.assoc_handle
      assoc = Assoc.find :first, :conditions=>['handle=?',openid.assoc_handle]

      if (not assoc) or (assoc.expires_in <= 0)
        assoc.destroy if assoc
        reply['openid.invalidate_handle'] = openid.assoc_handle
        openid.assoc_handle = nil
      end
    end

    if not openid.assoc_handle
      assoc = Assoc.new
      assoc.save!
      assoc.identity = openid.identity
      openid.assoc_handle = assoc.handle
    end

    reply['openid.assoc_handle'] = openid.assoc_handle
    reply['openid.signed'] = @@SIGNED_FIELDS.join(',')
    reply['openid.sig'] = reply.sign(assoc.secret, @@SIGNED_FIELDS)

    redirect_to URI.parse(openid.return_to).add_params(reply).to_s
  end

  # Exception to be thrown for missing or invalid fields
  class BadRequest < Exception
    attr_reader :field
    def initialize field
      @field = field
    end
  end

  # provide convenient access to openid params
  def openid
    if ! @openid
      @openid = params.dup
      @openid['openid.controller'] = self
      def @openid.method_missing symbol, *args
        if symbol.to_s[-1] == ?=
          self['openid.'+symbol.to_s[0..-2]]=args[0]
        elsif symbol.to_s[-1] == ?!
          self['openid.'+symbol.to_s[0..-2]] or controller.bad_request(symbol)
        else
          self['openid.'+symbol.to_s]
        end
      end
    end
    @openid
  end

end
