Skip to content

Latest commit

 

History

History
716 lines (511 loc) · 28.2 KB

APPROOV_TOKEN_BINDING_QUICKSTART.md

File metadata and controls

716 lines (511 loc) · 28.2 KB

Approov Token Binding Quickstart

This quickstart is for developers familiar with Phoenix who are looking for a quick intro into how they can add Approov into an existing project. Therefore this will guide you through the necessary steps for adding Approov with token binding to an existing Elixir Phoenix Absinthe GraphQL server.

TOC - Table of Contents

Why?

To lock down your GraphQL API server to your mobile app. Please read the brief summary in the Approov Overview at the root of this repo or visit our website for more details.

TOC

How it works?

For more background, see the Approov Overview at the root of this repo.

Approov Token Check

Take a look at the Approov Token Plug module to see how the Approov token check is invoked in the call/2 function. To see the simple code for the Approov token check, you need to look into the verify/1 function in the Approov Token module.

Approov Token Binding Check

The Approov token binding check can only be performed after a successful Approov token check, because it uses the pay key from the claims of the decoded Approov token payload. Take a look at the Approov Token Binding Plug module to see how the Approov token binding check is invoked in the call/2 function. To see the simple code for the Approov token binding check you need to look into the verify_token_binding/1 function in the Approov Token module.

TOC

Requirements

To complete this quickstart you will need both the Phoenix and the Approov CLI tool installed.

TOC

Approov Setup

To use Approov with the Elixir Phoenix Absinthe GraphQL server we need a small amount of configuration. First, Approov needs to know the API domain that will be protected. Second, the Elixir Phoenix Absinthe GraphQL server needs the Approov Base64 encoded secret that will be used to verify the tokens generated by the Approov cloud service.

Configure API Domain

Approov needs to know the domain name of the API for which it will issue tokens.

Add it with:

approov api -add your.api.domain.com

NOTE: By default a symmetric key (HS256) is used to sign the Approov token on a valid attestation of the mobile app for each API domain it's added with the Approov CLI, so that all APIs will share the same secret and the backend needs to take care to keep this secret secure.

A more secure alternative is to use asymmetric keys (RS256 or others) that allows for a different keyset to be used on each API domain and for the Approov token to be verified with a public key that can only verify, but not sign, Approov tokens.

To implement the asymmetric key you need to change from using the symmetric HS256 algorithm to an asymmetric algorithm, for example RS256, that requires you to first add a new key, and then specify it when adding each API domain. Please visit Managing Key Sets on the Approov documentation for more details.

Adding the API domain also configures the dynamic certificate pinning setup, out of the box.

NOTE: By default the pin is extracted from the public key of the leaf certificate served by the domain, as visible to the box executing the Approov CLI command and the Approov servers.

Approov Secret

Approov tokens are signed with a symmetric secret. To verify tokens, we need to grab the secret using the Approov secret command and plug it into the Elixir Phoenix Absinthe GraphQL server environment to check the signatures of the Approov Tokens that it processes.

First, enable your Approov admin role with:

eval `approov role admin`

For the Windows powershell:

set APPROOV_ROLE=admin:___YOUR_APPROOV_ACCOUNT_NAME_HERE___

Next, retrieve the Approov secret with:

approov secret -get base64url

Set the Approov Secret

Export the Approov secret into the environment:

export APPROOV_BASE64URL_SECRET=approov_base64url_secret_here

For the Approov secret to be available during runtime you need to add some code to the Elixir configuration, but Elixir has compile time and runtime configuration. To not duplicate the Approov secret configuration we recommend you to add it only in the runtime configuration.

From Elixir 1.11

If doesn't exist already, create the file config/runtime.exs and then add:

import Config

approov_secret =
  System.get_env("APPROOV_BASE64URL_SECRET") ||
    raise "Environment variable APPROOV_BASE64URL_SECRET is missing."

config :YOUR_APP, ApproovToken,
  secret_key: approov_secret |> Base.url_decode64!(padding: false)

The runtime configuration will run every-time a release or a Mix project is started.

From Elixir 1.9 to 1.10

The runtime configuration in this versions is config/releases.exs, but with the difference that it only runs when a release is booted, not when a Mix project is started, where only the compile time configuration is used.

To avoid duplicating the code for reading the Approov secret in both configurations you may want to import the runtime configuration from within your compile time configuration.

Add to config/releases.exs:

import Config

approov_secret =
  System.get_env("APPROOV_BASE64URL_SECRET") ||
    raise "Environment variable APPROOV_BASE64URL_SECRET is missing."

config :YOUR_APP, ApproovToken,
  secret_key: approov_secret |> Base.url_decode64!(padding: false)

config :YOUR_APP, YOUR_APP.Endpoint, server: true

Then import this file from config/config.exs:

import_config "releases.exs"

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"

With this approach you are in fact emulating the Elixir 1.11 behaviour for the runtime configuration config/runtime.exs.

Until Elixir 1.8

Until Elixir 1.8 version no official way existed of reading values for each time the server was started from a release or a Mix project, and lots of different workarounds existed, and you can see some of them being discussed in the Elixir Forum, like in this topic.

TOC

Approov Token Check

To check the Approov token we will use the joken-elixir/joken package.

First, add the Approov Token Plug module to your project at lib/your_app_web/plugs/approov_token_plug.ex:

defmodule YourAppWeb.ApproovTokenPlug do
  require Logger

  ##############################################################################
  # Adhere to the Phoenix Module Plugs specification by implementing:
  #   * init/1
  #   * call/2
  #
  # @link httpS://hexdocs.pm/phoenix/plug.html#module-plugs
  ##############################################################################

  # Don't use this function to init the Plug with the Approov secret, because
  # this is only evaluated at compile time, and we don't want the to have
  # secrets inside a release. Secrets must always be retrieved from the
  # environment where the release is running.
  def init(opts), do: opts

  # Allows to use the GraphqiQL web interface without requiring the Approov
  # token that is required for all requests in production.
  if Mix.env() in [:dev, :test] do
    # Allows to load the web interface for GraphiQL at `example.com/graphiql`
    # without checking for the Approov token.
    def call(%{method: "GET", request_path: "/graphiql"} = conn, _options), do: conn

    # The GraphqiQL web interface does some introspection queries to help with
    # validation and auto-completion, therefore we must allow them without
    # the need for an Approov token.
    def call(%{method: "POST", request_path: "/graphiql", params: %{"query" => "\n  query IntrospectionQuery" <> _query}} = conn, _options), do: conn
  end

  def call(conn, _opts) do
    case ApproovToken.verify_token(conn) do
      {:ok, approov_token_claims} ->
        conn
        |> Plug.Conn.put_private(:approov_token_claims, approov_token_claims)

      {:error, _reason} ->
        conn
        |> _halt_connection()
    end
  end

  # When the Approov token validation fails we return a `401` with an empty body,
  # because we don't want to give clues to an attacker about the reason the
  # request failed, and you can go even further by returning a `400`. Feel free
  # to modify as you see fits best your use case.
  defp _halt_connection(conn) do
    conn
    |> Plug.Conn.put_status(401)
    |> Phoenix.Controller.json(%{})
    |> Plug.Conn.halt()
  end
end

NOTE: When the Approov token validation fails we return a 401 with an empty body, because we don't want to give clues to an attacker about the reason the request failed, and you can go even further by returning a 400.

Next, add the Approov Token Binding Plug module to your project at lib/your_app_web/plugs/approov_token_binding_plug.ex:

defmodule YourAppWeb.ApproovTokenBindingPlug do

  ##############################################################################
  # Adhere to the Phoenix Module Plugs specification by implementing:
  #   * init/1
  #   * call/2
  #
  # @link https://hexdocs.pm/phoenix/plug.html#module-plugs
  ##############################################################################

  def init(opts), do: opts

  # Allows to use the GraphqiQL web interface without requiring the Approov
  # token that is required for all requests in production.
  if Mix.env() in [:dev, :test] do
    # Allows to load the web interface for GraphiQL at `example.com/graphiql`
    # without checking for the Approov token.
    def call(%{method: "GET", request_path: "/graphiql"} = conn, _options), do: conn

    # The GraphqiQL web interface does some introspection queries to help with
    # validation and auto-completion, therefore we must allow them without
    # the need for an Approov token.
    def call(%{method: "POST", request_path: "/graphiql", params: %{"query" => "\n  query IntrospectionQuery" <> _query}} = conn, _options), do: conn
  end

  def call(conn, _opts) do
    case ApproovToken.verify_token_binding(conn) do
      :ok ->
        conn

      {:error, _reason} ->
        conn
        |> _halt_connection()
    end
  end

  defp _halt_connection(conn) do
    conn
    |> Plug.Conn.put_status(401)
    |> Phoenix.Controller.json(%{})
    |> Plug.Conn.halt()
  end
end

Next, add the Approov Token module to your project at lib/approov_token.ex:

defmodule ApproovToken do
  require Logger

  use Joken.Config

  @impl Joken.Config
  def token_config, do: default_claims(skip: [:aud, :iat, :iss, :jti, :nbf])

  # Verifies the token from an HTTP request or from a Websockets connection/event
  def verify_token(params) do
    with {:ok, approov_token} <- _get_approov_token(params),
         {:ok, approov_token_claims} <- _decode_and_verify(approov_token) do

      {:ok, approov_token_claims}
    else
      {:error, reason} ->
        Logger.info(%{approov_token_error: reason})
        {:error, reason}
    end
  end


  ########################
  # APPROOV TOKEN FETCH
  ########################

  # For when the Approov token is the header of a regular HTTP Request
  defp _get_approov_token(%Plug.Conn{} = conn) do
    case Plug.Conn.get_req_header(conn, "x-approov-token") do
      [] ->
        Logger.info("Approov token not in the headers. Next, try to retrieve from url query params.")
        Logger.info(%{headers: conn.req_headers, params: conn.params})
        _get_approov_token(conn.params)

      [approov_token | _] ->
        {:ok, approov_token}
    end
  end

  defp _get_approov_token(%{x_headers: x_headers})
    when is_list(x_headers) and length(x_headers) > 0
  do
    case Utils.filter_list_of_tuples(x_headers, "x-approov-token") do
      nil ->
        {:ok, Utils.filter_list_of_tuples(x_headers, "X-Approov-Token")}

      approov_token ->
        {:ok, approov_token}
    end
  end

  # Fetch for a Phoenix Channel event, where the token is provided in the event
  # payload.
  defp _get_approov_token(%{"x-approov-token" => approov_token}), do: {:ok, approov_token}
  defp _get_approov_token(%{"X-Approov-Token" => approov_token}), do: {:ok, approov_token}

  defp _get_approov_token(%{x_headers: x_headers}) when is_list(x_headers) do
    case Utils.filter_list_of_tuples(x_headers, "x-approov-token") do
      nil ->
        {:ok, Utils.filter_list_of_tuples(x_headers, "X-Approov-Token")}

      approov_token ->
        {:ok, approov_token}
    end
  end

  # Catch failure to fetch the Approov token from the WebSocket upgrade request
  # or from the Phoenix Channel event.
  defp _get_approov_token(_params) do
    {:error, :missing_approov_token}
  end


  ########################
  # APPROOV TOKEN CHECK
  ########################

  defp _decode_and_verify(approov_token) do
    secret = Application.fetch_env!(:todo, ApproovToken)[:secret_key]

    # call `verify_and_validate/2` injected by `use Joken.Config`
    case verify_and_validate(approov_token, Joken.Signer.create("HS256", secret)) do
      {:ok, %{"exp" => _expiration}} = result ->
        result

      # The library only checks the `exp` when present, and verifies successfully
      # without it, and doesn't have an option to enforce it.
      {:ok, _claims} ->
        {:error, :missing_expiration_time}

      result ->
        result
    end
  end


  ################################
  # APPROOV TOKEN BINDING CHECK
  ################################

  def verify_token_binding(%Plug.Conn{private: %{approov_token_claims: approov_token_claims}} = conn) do
    with {:ok, token_binding_header} <- _get_token_binding_header(conn),
         :ok <- _verify_approov_token_binding(approov_token_claims, token_binding_header)
    do
      :ok
    else
      {:error, reason} ->
        Logger.info(%{approov_token_binding_error: reason, approov_token_claims: approov_token_claims})
        {:error, reason}
    end
  end

  def verify_token_binding(approov_token_claims, %{} = params) do
    with {:ok, token_binding_header} <- _get_token_binding(params),
         :ok <- _verify_approov_token_binding(approov_token_claims, token_binding_header)
    do
      :ok
    else
      {:error, reason} ->
        {:error, reason}
    end
  end

  defp _get_token_binding_header(%Plug.Conn{} = conn) do
    # We use the Authorization token, but feel free to use another header in
    # the request. Bear in mind that it needs to be the same header used in the
    # mobile app to bind the request with the Approov token.
    case Plug.Conn.get_req_header(conn, "authorization") do
      [] ->
        Logger.info("Approov token binding header is missing. Next, try to retrieve from the url query params.")
        _get_token_binding(conn.params)

      [token_binding_header | _] ->

        {:ok, token_binding_header}
    end
  end

  defp _get_token_binding(%{"Authorization" => token}) when is_binary(token), do: {:ok, token}
  defp _get_token_binding(_params), do: {:error, :missing_approov_token_binding}

  defp _verify_approov_token_binding(
         %{"pay" => token_binding_claim} = _approov_token_claims,
         token_binding_header
       )
  do
    # We need to hash and base64 encode the token binding header, because that's
    # how it was included in the Approov token on the mobile app.
    token_binding_header_encoded =
      :crypto.hash(:sha256, token_binding_header)
      |> Base.encode64()

    case token_binding_claim === token_binding_header_encoded do
      true ->
        :ok

      false ->
        {:error, :approov_invalid_token_binding_header}
    end
  end

  defp _verify_approov_token_binding(_approov_token_claims, _token_binding_header) do
    {:error, :approov_token_missing_pay_claim}
  end

end

Now, create and use the pipelines for the Approov token checks at lib/your_app_web/router.ex:

pipeline :approov_token do
  # Ideally you will not want to add any other Plug before the Approov Token
  # check to protect your server from wasting resources in processing requests
  # not having a valid Approov token. This increases availability for your
  # users during peak time or in the event of a DoS attack(We all know the
  # BEAM design allows to cope very well with this scenarios, but best to play
  # in the safe side).
  plug YourAppWeb.ApproovTokenPlug
end

pipeline :approov_token_binding do
  plug YourAppWeb.ApproovTokenBindingPlug
end

pipeline :graphql do
  plug YourAppWeb.AbsintheContextPlug
end

scope "/auth" do
  pipe_through :api
  pipe_through :approov_token

  post "/signup", YourAppWeb.AuthController, :signup
  post "/login", YourAppWeb.AuthController, :login
end

scope "/dashboard" do
  pipe_through [:browser, :live_view_dashboard_auth]
  live_dashboard "/"
end

# The `/graphiql` endpoint exposes too much to attackers, thus it shouldn't
# be available in production.
if Mix.env() in [:dev, :test] do
  scope "/graphiql" do
    pipe_through :approov_token
    pipe_through :approov_token_binding
    pipe_through :graphql

    forward "/", Absinthe.Plug.GraphiQL,
      schema: YourAppWeb.Schema,
      socket: YourAppWeb.UserSocket,
      log: false
  end
end

# Needs to be after the /graphiql endpoint scope, otherwise we get this API,
# instead of the expected /graphiql web interface.
scope "/" do
  pipe_through :api
  pipe_through :approov_token
  pipe_through :approov_token_binding
  pipe_through :graphql

  forward "/", Absinthe.Plug,
    schema: YourAppWeb.Schema,
    log: false
end

IMPORTANT: The Approov token binding check needs to come always after the Approov token check itself, because it needs to use the claims from the decoded Approov token payload.

NOTE: When using the Authorization token as the token binding you cannot use the :approov_token_binding pipeline for the Authorization API endpoints, as per the above example.

A full working example for a simple Todo GraphQL server can be found at src/approov-protected-server/token-binding-check/todo.

TOC

Approov Websocket Token Check

This step is only necessary if you want to protect the HTTPs request to establish a socket connection, like when Absinthe subscriptions or Phoenix Channels are used.

Unfortunately the Phoenix socket implementation only allows to retrieve headers from the HTTPs request establishing the socket connection when they start with an x, also known as the prefix for non standard HTTPs headers.

To enable retrieving the x headers, add connect_info: [:x_headers] to your socket configuration in the file endpoint.ex. It should look similar to this:

# lib/your_app_web/endpoint.ex

socket "/socket", YourAppWeb.UserSocket,
  websocket: [
    compress: true,
    connect_info: [
      :x_headers, # ADD THIS LINE TO YOUR WEBSOCKET CONFIGURATION
    ],
  ],

This will enable to retrieve the X_Approov_Token header from the HTTPs request establishing the socket connection and will make it available under the second parameter in the connect/2 callback when implementing the PhoenixSocket behaviour, that usually is named as connect_info.

To perform the Approov token check for when a websocket connection is established you need to modify the Phoenix socket behaviour implementation for connect/2 to check the Approov token with a call to ApproovToken.verify(connect_info, _approov_jwk()), just like you have done before in the ApproovTokenPlug for protecting the HTTPs requests.

Example of a simplified Phoenix Socket behaviour implementation with the Approov token check:

# lib/your_app_web/channels/user_socket.ex

defmodule YourAppWeb.UserSocket do
  use Phoenix.Socket

  use Absinthe.Phoenix.Socket, schema: TodoWeb.Schema

  @impl true
  def connect(params, socket, connect_info) do
    socket
    |> _authorize(params, connect_info)
  end

  @impl true
  def id(_socket), do: nil

  defp _authorize(socket, params, connect_info) do
    # We need to merge them because the requests from the GraphiQL web interface doesn't populate the `connect_info` with the Approov token.
    headers = Map.merge(params, connect_info)

    # Always perform the Approov token check before the User Authentication.
    with {:ok, _approov_token_claims} <- ApproovToken.verify_token(headers),
         {:ok, current_user} <- Todos.User.authorize(params: params) do

      socket = Absinthe.Phoenix.Socket.put_options(socket, context: %{current_user: current_user})

      {:ok, socket}
    else
      {:error, _reason} ->
        :error
    end
  end

end

NOTE: Putting sensitive data in an url query parameter is not a best security practice, thus you should avoid as much as possible to put it there. You may think that once the request is over https it isn't an issue, but you need to remember that the full url, including the query parameters, are often logged by applications, load balancers, API gateways, etc., thus causing any sensitive data on them to be leaked to the logs. Attackers usually build their attacks based on a chain of exploits, like getting the token from a compromised logging server and subsequently use it on automated or manual attacks. Just search in shodan.io for your logging server of choice to see how many are left accidentally publicly exposed to the internet, and attackers have automated tools scanning non-stop for them.

TOC

Test your Approov Integration

The following examples below use cURL, but you can also use the graphiql/graphiql-workspace-approov-token-binding-check.json workspace in the GraphiQL web interface for your testing purposes. Just remember that you need to adjust the urls and tokens defined in the workspace to match your deployment. Alternatively, the root README.md also contains instructions for using the preset dummy secret to test your Approov integration with the provided GraphiQL workspace.

User Signup and Login

Generate a valid token example from the Approov Cloud service:

approov token -genExample your.api.domain.com

Let's signup the user in the Approov protected endpoint:

curl -i --request POST 'https://your.api.domain.com/auth/signup' \
  --header 'Approov-Token: APPROOV_VALID_TOKEN_EXAMPLE_HERE' \
  --data username=test@mail.com \
  --data password=your-pass-here

The request should be accepted. For example:

HTTP/2 200
...

{"id":"B4C20F1BBC39F610CF4608F83A06DEA85884D77C854BBB3D10EF239641B9B861"}

Next, let's login the user in the Approov protected endpoint:

curl -i --request POST 'your.api.domain.com/auth/login' \
  --header 'Approov-Token: APPROOV_VALID_TOKEN_EXAMPLE_HERE' \
  --data username=test@mail.com \
  --data password=your-pass-here

The request should be accepted. For example:

HTTP/2 200
...

{"token":"Bearer AUTHORIZATION_VALID_TOKEN"}

Finally we have the Bearer Authorization token that is required to represent a logged in user when doing the GraphQL queries to the backend. To note that the backend only checks the Authorization token after it is able to successfully check the Approov token.

Test for Regular Http Requests

With Valid Approov Tokens

Generate a valid Approov token binding example from the Approov Cloud service:

approov token -setDataHashInToken 'Bearer AUTHORIZATION_VALID_TOKEN' -genExample your.api.domain.com

Then make the request with the generated token:

curl -i --request POST 'your.api.domain.com' \
  --header 'Approov-Token: APPROOV_VALID_TOKEN_BINDING_EXAMPLE_HERE' \
  --header 'Authorization: Bearer AUTHORIZATION_VALID_TOKEN' \
  --header "Content-Type: application/json" \
  --data '{"query":"mutation CreateTodo($title: String!) {\n  createTodo(title: $title, isPublic: false) {\n    id\n    title\n    isCompleted\n  }\n}","variables":{"title":"task me 1"},"operationName":"CreateTodo"}'

The request should be accepted. For example:

HTTP/2 200

...

{"data":{"createTodo":{"id":322,"isCompleted":false,"title":"task me 1"}}}

With Invalid Approov Token Binding

Make the request with the same valid Approov token binding generated token, but with an Authorization header different from the one you used for -setDataHashInToken 'Bearer AUTHORIZATION_VALID_TOKEN'.

For example:

curl -i --request POST 'your.api.domain.com' \
  --header 'Approov-Token: APPROOV_VALID_TOKEN_BINDING_EXAMPLE_HERE' \
  --header 'Authorization: Bearer AUTHORIZATION_ANOTHER_VALID_TOKEN' \
  --header "Content-Type: application/json" \
  --data '{"query":"mutation CreateTodo($title: String!) {\n  createTodo(title: $title, isPublic: false) {\n    id\n    title\n    isCompleted\n  }\n}","variables":{"title":"task me invalid 1"},"operationName":"CreateTodo"}'

The above request should fail with an Unauthorized error. For example:

HTTP/2 401

...

{}

The Authorization header in this request is not the same used for the Approov token binding, thus the backend fails to validate the token binding, despite the Approov token itself being correctly signed and not expired.

Go ahead and try to repeat the request, but this time do not send the Authorization header.

TOC

Test for Websockets

The Phoenix Absinthe Sockets are not easily tested using cURL, because you need to establish a websocket connection and keep it open.

You can test the Absinthe Socket subscriptions by using the Absinthe Graphiql web interface at your.api.domain.com/graphiql. To make it easier to test you can upload to the web interface this graphiql workspace: graphiql/graphiql-workspace-approov-token-binding-check.json and adapt it for your use case.

ALERT: If you have the GraphiQL web interface enabled in a server accessible from the internet, then we strongly advise you to protect it with user Authentication and/or IP address white-list. For example, something like the TodoWeb.LiveViewDashboardAuthPlug used in the server examples of this repo, that are based on the Plug.BasicAuth package.

Another alternative is to use an online websocket client from a browser, and you can check this Stackoverflow question to see some suggestions.

Remember that for establishing the websocket connection you need to set the X-Approov-Token header in the request.

To generate a valid token example from the Approov Cloud service:

approov token -genExample your.api.domain.com

To generate an invalid token example from the Approov Cloud service:

approov token -type invalid -genExample your.api.domain.com

TOC