require 'base64' require 'json' require 'openssl' require 'yaml' require 'monk/id/version' # Global Monk namespace. module Monk # Integrate Monk ID on the server-side by accessing payloads from the # client-side JavaScript. # # @author Monk Development, Inc. module Id # Expected path of config file in Rails and Sinatra relative to the app's # root directory. CONFIG_FILE = 'config/monk_id.yml'.freeze # Name of the cookie that (optionally) stores the payload. COOKIE_NAME = '_monkIdPayload'.freeze class << self # Load a YAML config file for a specific environment. Rails and Sinatra # apps don't need to call this method if the config file is stored at # {CONFIG_FILE}, as it's loaded automatically. # # @param path [String] Path of YAML config file to load. Leave `nil` to # read from environment (`MONK_ID_CONFIG` variable, Rails, # Sinatra). # @param environment [String] Environment section to use. Leave `nil` to # read from environment (`MONK_ID_ENV` variable, Rails, Sinatra). # Defaults to `development`. # @raise [StandardError] If the file doesn't exist or can't be read. # @return [Hash] Loaded config values. def load_config(path = nil, environment = nil) path ||= config_path_from_environment environment ||= config_environment config = YAML.load_file(path)[environment] valid_config?(config) @config = config end # Get a config value. Attempts to load the config if it hasn't already # been loaded. # # @param key [String] Name of config value. # @raise [StandardError] If the config can't be loaded. # @return [*] Config value. def config(key) load_config unless @config @config[key] end # Load a payload from the client-side. # # @param encoded_payload [String, #[]] Encoded payload or Hash-like # cookies object to automatically load the payload from. # @return [Hash] Decoded and validate payload. Empty if there's no # payload or it fails validation. def load_payload(encoded_payload = nil) payload = select_payload(encoded_payload) return @payload = {} unless payload begin payload = decode_payload(payload) valid = valid_payload?(payload) rescue valid = false end @payload = valid ? payload : {} end # Get the logged in user's UUID. # # @return [String] If logged in user. # @return [nil] If no logged in user. def user_id payload_user(:id) end # Get the logged in user's email address. # # @return [String] If logged in user. # @return [nil] If no logged in user. def user_email payload_user(:email) end # Check whether there's a logged in user. # # @return [Boolean] Whether there's a logged in user. def logged_in? !user_id.nil? end # @deprecated Since v1.2.0. Use {#logged_in?}. alias signed_in? logged_in? protected # Loaded config values. @config = nil # Loaded payload. @payload = nil # Get the path to the config file from the environment. Supports `ENV` # variable, Rails, and Sinatra. # # @return [String] Path to the config file. # @return [nil] If not set by the environment. def config_path_from_environment if ENV['MONK_ID_CONFIG'] ENV['MONK_ID_CONFIG'] elsif defined? Rails File.join(Rails.root, CONFIG_FILE) elsif defined? Sinatra File.join(Sinatra::Application.settings.root, CONFIG_FILE) end end # Get the environment to load within the config. Supports `ENV` variable, # Rails, and Sinatra. Defaults to `development` if none specify. # # @return [String] Environment name. def config_environment if ENV['MONK_ID_ENV'] ENV['MONK_ID_ENV'] elsif defined? Rails Rails.env elsif defined? Sinatra Sinatra::Application.settings.environment.to_s else 'development' end end # Validate that a config has all the required values. # # @param config [Hash] Config values. # @raise [RuntimeError] If invalid. # @return [true] If valid. def valid_config?(config) raise 'no config loaded' unless config raise 'no `app_id` config value' unless config['app_id'] raise 'no `app_secret` config value' unless config['app_secret'] true end # Select a payload from the first place one can be found. # # @param encoded_payload [String, #[]] Encoded payload or Hash-like # cookies object to select the payload from. # @return [String] Encoded payload. # @return [nil] If one can't be found. def select_payload(encoded_payload) if encoded_payload.is_a? String encoded_payload elsif encoded_payload.respond_to? :[] encoded_payload[COOKIE_NAME] end end # Decode a payload from the client-side. # # @param encoded_payload [String] Encoded payload. # @raise [JSON::ParserError] If invalid JSON. # @return [Hash] Decoded payload. def decode_payload(encoded_payload) JSON.parse(Base64.decode64(encoded_payload), symbolize_names: true) end # Generate the expected signature of a payload using the app's secret. # # @param payload [Hash] Decoded payload. # @return [String] Expected signature of the payload. def expected_signature(payload) payload_clone = payload.clone payload_clone[:user].delete(:signature) OpenSSL::HMAC.digest( OpenSSL::Digest::SHA512.new, config('app_secret'), JSON.generate(payload_clone[:user]) ) end # Validate that a payload hasn't been tampered with or faked by comparing # signatures. # # @param payload [Hash] Decoded payload. # @return [Boolean] Whether the payload is valid. def valid_payload?(payload) signature = Base64.decode64(payload[:user][:signature]) signature == expected_signature(payload) end # Get the loaded payload. # # @return [Hash] Loaded payload. Empty if there's no payload or it # failed validation. def payload @payload || load_payload end # Get a value from the `user` hash of the loaded payload. # # @param key [Symbol] Name of value. # @return [*] Requested value or `nil` if not set. def payload_user(key) payload = self.payload payload.key?(:user) ? payload[:user][key] : nil end end end end