app/features-json/api_controller.rb

Summary

Maintainability
A
1 hr
Test Coverage
require "sinatra/base"
require "sinatra/reloader"
require "jwt"

require_relative "../services/services"
require_relative "json_params"

module FastlaneCI
  ##
  # APIController is designed for state-less API requests servicing the Angular front-end
  #
  # Requests are expected to be application/json.
  # The `params` method can be used to access the JSON body as if it were a hash
  #
  # Authentication is enabled by default. It can be disabled for the entire controller,
  # and individual endpoints can be give an authentication conditions.
  #
  # Usage
  # ===
  # Subclass the APIController for any controller that you expect JSON requests and responses.
  #
  # Settings
  # ===
  #
  # The following settings are provided to subclasses of APIController:
  #
  # * `authentication`- boolean value that makes every request check authentication before each action.
  #    Disable with: `disable :authentication`. Enabled by default.
  # * `authenticate_via` - which authentication scheme to use. `:jwt` by default.
  # * `jwt_secret` - The key to use in decoding JWT tokens.
  #
  # Per-route Authentication
  # ===
  # If you disable authentication for the whole controller, you can enable it on a per-route basis using
  # a route condition like this:
  #
  #   get "/private", authenticate: :jwt do
  #     json({message: "secret"})
  #   end
  #
  # You may also selectively disable on a per-route basis by passing `authenticate: false` to the route:
  #
  #   get "/public", authenticate: false do
  #     json({message: "public"})
  #   end
  #
  # This will always bypass any authentication setting.
  #
  # User authentication
  # ===
  # This controller also provides a few helper methods that return information about the current user
  # * `user_id` - the user id encoded in the jwt.
  # * `current_user` - the user model fetched from the data service, or nil if it cannot be found.
  # * `user_logged_in?` - boolean whether the user is found.
  #
  class APIController < Sinatra::Base
    include JSONParams
    include Logging

    configure(:development) do
      register Sinatra::Reloader
    end

    configure(:production, :development) do
      enable(:logging)
    end

    # to disabled authentication for the entire controller use:
    # disable(:authentication)
    set(:authentication, true)

    # the default authentication scheme. We only support `:jwt` at this time.
    set(:authenticate_via, :jwt)

    # the condition can be added to any route
    set(:authenticate) do |auth_type|
      condition { authenticate!(via: auth_type) }
    end

    # the JWT secret uses the fastlane encryption key
    set(:jwt_secret, FastlaneCI.dot_keys.encryption_key)
    set(:jwt_algo, "HS256")

    ##
    # override the route by injecting the `authenticate` condition.
    # use this instead of adding a `before` block which are terrible.
    def self.route(verb, path, options = {}, &block)
      if settings.authentication? && !options.key?(:authenticate)
        options[:authenticate] = settings.authenticate_via
      end

      super
    end

    helpers do
      # Decode the JWT or halt.
      def jwt
        authorization = request.env["HTTP_AUTHORIZATION"]
        bearer_token = authorization && authorization.slice(7..-1) # strip off the `Bearer `

        # give the option to pass the bearer token as a query param.
        # this is used when making websocket connections which do not allow us to set http headers
        bearer_token ||= request.params["bearer_token"]

        payload, _header = JWT.decode(
          bearer_token,
          settings.jwt_secret,
          true, # Validate Issuer?
          { verify_iss: true, verify_iat: true, algorithm: "HS256", iss: "fastlane.ci" } # Options
        )

        return payload
      rescue JWT::InvalidIssuerError
        json_error!(
          error_message: "The token does not have a valid issuer",
          error_key: "Authentication.Token.InvalidIssuer",
          error_code: 403
        )
      rescue JWT::InvalidIatError
        json_error!(
          error_message: "The token does not have a valid 'issued at' time",
          error_key: "Authentication.Token.MissingIssuedTime",
          error_code: 403
        )
      rescue JWT::ExpiredSignature
        json_error!(
          error_message: "The token has expired",
          error_key: "Authentication.Token.Expired",
          error_code: 401
        )
      rescue JWT::DecodeError
        json_error!(
          error_message: "A token must be passed",
          error_key: "Authentication.Token.Missing",
          error_code: 401
        )
      end

      # dispatch to the different authentication schemes available.
      def authenticate!(via:)
        logger.info("Authenticating via #{via}")

        case via
        when :jwt
          return jwt
        when false
          logger.info("Skipping authentication.")
        else
          raise "`#{via}` is an un-supported authentication scheme."
        end
      end

      # if more `authenticate_via`` options get added, change this method
      def user_id
        payload = authenticate!(via: :jwt)
        return payload["user"]
      end

      # provides access to the User model. Memoize @current_user so we don't do unnecessary i/o.
      def current_user
        @current_user ||= FastlaneCI::Services.user_service.find_user(id: user_id)
        unless @current_user
          json_error!(
            error_message: "User not found",
            error_key: "Authentication.UserNotFound",
            error_code: 404
          )
        end

        return @current_user
      end

      def user_logged_in?
        current_user != nil
      end

      # @param error_message [String]: A human readable error message
      # @param error_key [String]: a machine readable error code, nested with .
      #                            check out docs/API.md for more information
      # @param error_message [String]: optional, HTTP error code
      def json_error!(error_message:, error_key:, error_code: 400)
        logger.info("Error #{error_key} (#{error_code})")

        # Better have "Unknown" than an empty string or `nil`
        error_key = "Unknown" if error_key.to_s.length == 0

        # Set the HTTP status for Sinatra
        status(error_code)

        # Use `halt` to immediately return the error
        # to the client
        halt(error_code, json({
          message: error_message,
          key: error_key
        }))
      end

      def current_user_provider_credential
        provider_credential = current_user.provider_credential
        unless provider_credential
          json_error!(
            error_message: "Provider Credential not found",
            error_key: "Authentication.ProviderCredentialNotFound",
            error_code: 404
          )
        end

        return provider_credential
      end

      def current_user_config_service
        FastlaneCI::ConfigService.new(ci_user: current_user)
      end
    end
  end
end