require 'omniauth' require 'tty-spinner' module Chronicle module ETL # An authorization strategy that uses oauth2 (and omniauth under the hood) class OauthAuthorizer < Authorizer class << self attr_reader :strategy, :provider_name, :authorization_to_secret_map attr_accessor :client_id, :client_secret # Macro for specifying which omniauth strategy to use def omniauth_strategy(strategy) @strategy = strategy end # Macro for specifying which omniauth scopes to request def scope(value) options[:scope] = value end # Macro for specifying hash of returned authorization to secrets hash def pluck_secrets(map) @authorization_to_secret_map = map end # # Macro for specifying options to pass to omniauth def options @options ||= {} end # Returns all subclasses of OauthAuthorizer # (Used by AuthorizationServer to build omniauth providers) def all ObjectSpace.each_object(::Class).select { |klass| klass < self } end end attr_reader :authorization # Create a new instance of OauthAuthorizer def initialize(port:, credentials: {}) @port = port @credentials = credentials super end # Start up an authorization server and handle the oauth flow def authorize! associate_oauth_credentials @server = load_server spinner = TTY::Spinner.new(':spinner :title', format: :dots_2) spinner.auto_spin spinner.update(title: "Starting temporary authorization server on port #{@port}"'') server_thread = start_authorization_server(port: @port) start_oauth_flow spinner.update(title: 'Waiting for authorization to complete in your browser') sleep 0.1 while authorization_pending?(server_thread) @server.quit! server_thread.join spinner.success("(#{'successful'.green})") # TODO: properly handle failed authorizations raise Chronicle::ETL::AuthorizationError unless @server.latest_authorization @authorization = @server.latest_authorization extract_secrets(authorization: @authorization, pluck_values: self.class.authorization_to_secret_map) end private def authorization_pending?(server_thread) server_thread.status && !@server.latest_authorization end def associate_oauth_credentials self.class.client_id = @credentials[:client_id] self.class.client_secret = @credentials[:client_secret] end def load_server # Load at runtime so that we can set omniauth strategies based on # which chronicle plugin has been loaded. require_relative 'authorization_server' Chronicle::ETL::AuthorizationServer end def start_authorization_server(port:) @server.settings.port = port suppress_webrick_logging(@server) Thread.abort_on_exception = true Thread.report_on_exception = false Thread.new do @server.run!({ port: @port }) do |s| s.silent = true if defined?(::Thin::Server) && s.instance_of?(::Thin::Server) end end end def start_oauth_flow url = "http://localhost:#{@port}/auth/#{omniauth_strategy}" Launchy.open(url) rescue Launchy::CommandNotFoundError Chronicle::ETL::Logger.info("Please open #{url} in a browser to continue") end def suppress_webrick_logging(server) require 'webrick' server.set( :server_settings, { AccessLog: [], # TODO: make this windows friendly # https://github.com/winton/stasis/commit/77da36f43285fda129300e382f18dfaff48571b0 Logger: WEBrick::Log.new('/dev/null') } ) rescue LoadError # no worries if we're not using WEBrick end def extract_secrets(authorization:, pluck_values:) return authorization unless pluck_values&.any? pluck_values.transform_values do |identifiers| authorization.dig(*identifiers) end end def omniauth_strategy self.class.strategy end end end end