How to add HTTP Basic auth to Amber application
Tags: crystal, amberframework, amber
Published: 2019-11-25 21:44:17

As you already [may know](https://twitter.com/igor_alexandrov/status/1166754279631327232), one of our projects have a [Crystal](https://crystal-lang.org/) application in production. It is created with [Amber framework](https://amberframework.org/) and works just perfect.

The only thing that I don't personally like in Amber is not clear and sometimes outdated [documentation](https://docs.amberframework.org/amber/), after about 11 years with Rails I still think that Rails Guides are the number one developer documentation in the world.

I had a task to add HTTP Basic auth to a couple of URLs in Amber application, and after studying documentation found that Amber doesn't provide necessary [Pipe](https://docs.amberframework.org/amber/guides/routing/pipelines) out of the box. Ok, next place to search for the answer was [Gitter](https://gitter.im/amberframework/amber) and after about an hour [Dru Jensen](https://github.com/drujensen) helped me with a code example.

Amber uses internally [HTTP::Handler](https://crystal-lang.org/api/0.31.1/HTTP/Handler.html) for Pipes as well as Kemal does for [Middlewares](https://kemalcr.com/guide/#middleware), so we can easily use code from [Basic Auth for Kemal](https://github.com/kemalcr/kemal-basic-auth).

```ruby
# src/pipes/http_basic_auth_pipe.cr

require "crypto/subtle"

class HTTPBasicAuthPipe
  include HTTP::Handler
  BASIC = "Basic"
  AUTH = "Authorization"
  AUTH_MESSAGE = "Could not verify your access level for that URL.\nYou have to login with proper credentials"
  HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""

  property credentials : Credentials?

  def initialize(@credentials : Credentials)
  end

  def initialize(username : String, password : String)
    initialize({ username => password })
  end

  def initialize(hash : Hash(String, String))
    initialize(Credentials.new(hash))
  end

  def initialize
    if ENV["HTTP_BASIC_USERNAME"]? && ENV["HTTP_BASIC_PASSWORD"]?
      initialize(ENV["HTTP_BASIC_USERNAME"], ENV["HTTP_BASIC_PASSWORD"])
    end
  end

  def call(context)
    if credentials
      if context.request.headers[AUTH]?
        if value = context.request.headers[AUTH]
          if value.size > 0 && value.starts_with?(BASIC)
            return call_next(context) if authorized?(value)
          end
        end
      end
      headers = HTTP::Headers.new
      context.response.status_code = 401
      context.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
      context.response.print AUTH_MESSAGE
    else
      call_next(context)
    end
  end

  private def authorized?(value)
    username, password = Base64.decode_string(value[BASIC.size + 1..-1]).split(":")
    credentials.not_nil!.authorize?(username, password)
  end

  class Credentials
    def initialize(@entries : Hash(String, String) = Hash(String, String).new)
    end

    def authorize?(username : String, given_password : String) : String?
      test_password = find_password(username, given_password)
      if Crypto::Subtle.constant_time_compare(test_password, given_password)
        username
      else
        nil
      end
    end

    private def find_password(username, given_password)
      # return a password that cannot possibly be correct if the username is wrong
      pw = "not #{given_password}"

      # iterate through each possibility to not leak info about valid usernames
      @entries.each do |(user, password)|
        if Crypto::Subtle.constant_time_compare(user, username)
          pw = password
        end
      end

      pw
    end
  end
end
```

And in `routes.cr`:

``` ruby
  # ...
  pipeline :api do
    # ...
    plug HTTPBasicAuthPipe.new
  end
  # ...
```