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.
- Why?
- How it Works?
- Requirements
- Approov Setup
- Approov Token Check
- Try the Approov Integration Example
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.
For more background, see the Approov Overview at the root of this repo.
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.
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.
To complete this quickstart you will need both the Phoenix and the Approov CLI tool installed.
- Phoenix - Follow the official installation instructions from here
- Approov CLI - Follow our installation instructions and read more about each command and its options in the documentation reference
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.
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 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
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.
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.
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
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.
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 a400
.
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.
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.
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.
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.
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"}}}
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.
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 thePlug.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