Current section

Files

Jump to
plug_shopify_jwt lib plug_shopify_jwt.ex
Raw

lib/plug_shopify_jwt.ex

defmodule PlugShopifyEmbeddedJWTAuth do
@moduledoc """
This plug validates Shopify JWT - also known as session token authentication.
Session tokens/JWT are a replacement for cookie based authentication in embedded apps.
PlugShopifyJwt is architected to support Session tokens whilst allowing you to
verify with URL parameters (validation of URL parameters is not included in this plug) should you decide.
### Usage
Grab you app secret, and crack open your router.ex file, insert
`plug PlugShopifyEmbeddedJWTAuth, [secret: "your-secret"]`. A basic setup looks something similar to this:
```elixir
pipeline :embedded do
plug PlugShopifyEmbeddedJWTAuth, [secret: "224e5146-4f1e-4a1d-a64a-2732df659542"]
end
scope "/api", HelloPhoenixWeb do
pipe_through :embedded
get "/show", PageController, :show
end
```
### Installation
The package can be installed by adding `plug_shopify_jwt` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:plug_shopify_jwt, "~> 0.1.0"}
]
end
```
"""
import Plug.Conn
@typedoc """
### Plug configuration
- algorithm (Default: `"HS256"`) - "HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "Ed25519", "Ed25519ph", "Ed448".
- secret (**Required** the plug will throw an error if you do not set this) - from your Shopify Partner Dashboard.
- halt_on_error (Default: `true`) - either `true` or `false`
- signer (App only) - this is set by the Plug, you do not need to set this item.
"""
@type config :: [
algorithm: String.t(),
secret: String.t() | nil,
signer: Joken.Signer.t() | nil,
halt_on_error: true | false
]
@doc """
Protects a route with JWT. Plug will automatically call `call/2` for each connection.
### Config
See the documentation for the `config` type for details on the available options.
### Usage:
```elixir
pipeline :embedded do
plug PlugShopifyEmbeddedJWTAuth, [secret: "224e5146-4f1e-4a1d-a64a-2732df659542"]
end
scope "/api", HelloPhoenixWeb do
pipe_through :embedded
get "/show", PageController, :show
end
```
"""
@spec init(config) :: config
def init(opts) do
opts
|> prepare_cfg(Application.get_all_env(:plug_shopify_jwt))
|> validate_secret()
|> set_signer()
end
@doc """
Pass in a `Plug.Conn` and `Config` and it will take the auth header, authenticate the request, and the result will be one of the following:
1. *Return a 401*: `halt_on_error` must be set to `true`, and the authentication fails.
2. *Allow the connection to continue*: When authentication passes we will set `conn.private[:shopify_jwt_claims]` to the full decoded JWT, and `conn.private[:current_shop_name]` - will be the myshopify.com domain for the current store, e.g. `example.shopify.com`
3. *Fail, but allow the connection to continue*: When `halt_on_error` is set to `false` instead of halting the connection the plug will set `conn.private[:ps_jwt_success]` to `false` assuming you will be doing error handling in another place.
"""
@spec call(Plug.Conn.t(), config) :: Plug.Conn.t()
def call(conn = %{private: %{shop_origin_type: :jwt}}, opts) do
conn
|> get_auth_header
|> authenticate(opts)
|> respond
end
def call(conn, _), do: conn
@spec respond(
{:ok, nil | maybe_improper_list | map, Plug.Conn.t()}
| {:error, any, any, nil | keyword | map}
) :: Plug.Conn.t()
def respond({:ok, claims, conn}) do
success(conn, claims)
end
def respond({:error, error, conn, opts}) do
halt = Access.get(opts, :halt_on_error, true)
error(conn, halt, %{message: error})
end
#
# Auth session
#
defp authenticate({conn, "Bearer " <> jwt}, opts) do
with {:ok, jwt} <- valid_jwt?(jwt),
{:ok, jwt_claims} <- Joken.verify(jwt, opts[:signer]) do
{:ok, jwt_claims, conn}
else
{:error, reason} -> {:error, "#{inspect(reason)}", conn, opts}
error -> {:error, error, conn, opts}
end
end
defp authenticate({conn, _}, opts) do
{:error, %{message: "Missing authenticate header"}, conn, opts}
end
defp authenticate(conn, opts) do
{:error, %{message: "Missing authenticate header"}, conn, opts}
end
defp get_auth_header(conn) do
case get_req_header(conn, "authorization") do
[token] -> {conn, token}
_ -> conn
end
end
# Split the string on the dot
defp valid_jwt?(jwt) do
jwt |> String.split(".") |> valid_jwt?(jwt)
end
# Make sure there are three parts to that string
defp valid_jwt?(split_jwt, jwt)
when is_list(split_jwt) and (length(split_jwt) == 2 or length(split_jwt) == 3) do
{:ok, jwt}
end
# Make sure there are three parts to that string
defp valid_jwt?(split_jwt, _jwt)
when is_list(split_jwt) and not (length(split_jwt) == 2 or length(split_jwt) == 3) do
{:error, "jwt malformed"}
end
#
# Callbacks
#
defp success(conn, claims) do
conn
|> attach_success_private(claims)
|> put_private(:ps_jwt_success, true)
end
defp error(conn, true, reason) do
conn
|> send_401(Map.merge(%{message: "Validation error"}, reason))
|> halt
end
defp error(conn, false, _reason) do
conn
|> put_private(:ps_jwt_success, false)
end
#
# Responses
#
defp attach_success_private(conn, claims) do
conn
|> put_private(:shopify_jwt_claims, claims)
|> put_private(:current_shop_name, String.replace_prefix(claims["dest"], "https://", ""))
end
defp send_401(conn, reason) do
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(reason))
end
# Plug setup
defp defaults,
do: [
algorithm: "HS256",
secret: nil,
signer: nil,
halt_on_error: true
]
defp prepare_cfg(opts, env) do
defaults()
|> Keyword.merge(env)
|> Keyword.merge(opts)
end
defp set_signer(opts),
do: Keyword.merge(opts, signer: Joken.Signer.create(opts[:algorithm], opts[:secret]))
defp validate_secret(opts) do
if opts[:secret] == nil do
raise(
"NO_SECRET: Error starting PlugShopifyEmbeddedJWTAuth. You must include the configuration item `secret`."
)
else
opts
end
end
end