aboutsummaryrefslogtreecommitdiff
path: root/app/lib/sso/from_discourse.rb
blob: 7af7173d4e6ccd26e5433efae59454daeefa5fe6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# SPDX-FileCopyrightText: 2018-2020 IN COMMON Collective <collective@incommon.cc>
#
# SPDX-License-Identifier: AGPL-3.0-or-later

# frozen_string_literal: true

module SSO
  class MissingSecretError < ArgumentError; end

  class FromDiscourse
    attr_accessor :nonce, :token
    attr_reader   :request_uri, :user_info, :status

    class << self
      # See config/initializers/sso.rb
      # This is a hash:
      # SSO::FromDiscourse.config = {
      #   sso_url: 'https://talk.incommon.cc/session/sso_provider',
      #   return_url: 'https://incommon-map.example/authenticate',
      #   sso_secret: Rails.application.credentials.sso_secret,
      # }
      # In config/routes.rb:
      # ...
      # get 'authenticate/(:token)' => 'authentications#sso_login'
      attr_accessor :config
    end

    def initialize(options = {})
      @nonce     = options[:nonce] || SecureRandom.hex
      @token     = options[:token] || SecureRandom.hex
      @user_info = nil
      @status    = :unauthorized
    end

    def parse(params)
      params[:sig] =~ %r{\A[a-f0-9]{64}\z}      || raise(ArgumentError, 'HMAC invalid')
      hmac_matches?(params[:sso], params[:sig]) || raise(ArgumentError, 'HMAC mismatch')
      base64?(params[:sso])                     || raise(ArgumentError, 'Encoding invalid')

      response = Rack::Utils.parse_query(Base64.decode64(params[:sso]))

      # Of course, the nonces must match!
      ActiveSupport::SecurityUtils
        .fixed_length_secure_compare(response['nonce'], @nonce) ||
        raise(ArgumentError, 'Nonce mismatch')

      response.delete('nonce')

      @user_info = response.symbolize_keys
      @status    = :ok

      self
    end

    def request_uri
      format('%<url>s?sso=%<payload>s&sig=%<hmac>s',
             url: config[:sso_url].to_s,
             payload: Rack::Utils.escape(b64_payload),
             hmac: mac_signature)
    end

    def success?
      @status == :ok
    end

    private

    def config
      self.class.config
    end

    # Estimate whether given data is encoded in Base64.
    # This is not fool-proof but good enough for our purpose.
    def base64?(data)
      data.is_a?(String) &&
        (data.length % 4).zero? &&
        data.match?(%r{\A[a-zA-Z\d+/]+={,2}\z})
    end

    def hmac_matches?(payload, signature)
      hmac = mac_signature(payload)
      ActiveSupport::SecurityUtils.fixed_length_secure_compare(hmac, signature)
    end

    def payload
      format('nonce=%<n>s&return_sso_url=%<u>s',
             n: @nonce,
             u: "#{config[:return_url]}/#{token}")
    end

    def b64_payload
      Base64.encode64(payload)
    end

    def mac_signature(payload = b64_payload)
      OpenSSL::HMAC.hexdigest('SHA256', sso_secret, payload)
    end

    def sso_secret
      @sso_secret = begin
                      self.class.config[:sso_secret] ||
                        Rails.application.credentials.sso_secret
                    rescue MissingConstant
                      nil
                    end
      raise SSO::MissingSecretError if @sso_secret.nil?
      self.class.config[:sso_secret] ||= @sso_secret
    end
  end
end