Skip to content

Commit

Permalink
feat: Add channels auth via JWT verification (#99)
Browse files Browse the repository at this point in the history
  • Loading branch information
w3b6x9 authored Jan 10, 2021
1 parent e7c0427 commit 0a288e0
Show file tree
Hide file tree
Showing 16 changed files with 537 additions and 27 deletions.
45 changes: 38 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Supabase Realtime

> **⚠ WARNING: v0.9.8 Breaking Change **
> Channels connections are secured by default in production. See [Channels Authorization](#channels-authorization) for more info.
Listens to changes in a PostgreSQL Database and broadcasts them over websockets.

<p align="center"><kbd><img src="./examples/next-js/demo.gif" alt="Demo"/></kbd></p>
Expand All @@ -18,6 +21,7 @@ Listens to changes in a PostgreSQL Database and broadcasts them over websockets.
- [Server](#server)
- [Database set up](#database-set-up)
- [Server set up](#server-set-up)
- [Channels Authorization](#channels-authorization)
- [Contributing](#contributing)
- [Releasing](#releasing)
- [License](#license)
Expand Down Expand Up @@ -144,15 +148,42 @@ docker run \
**OPTIONS**

```sh
DB_HOST # {string} Database host URL
DB_NAME # {string} Postgres database name
DB_USER # {string} Database user
DB_PASSWORD # {string} Database password
DB_PORT # {number} Database port
SLOT_NAME # {string} A unique name for Postgres to track where this server has "listened until". If the server dies, it can pick up from the last position. This should be lowercase.
PORT # {number} Port which you can connect your client/listeners
DB_HOST # {string} Database host URL
DB_NAME # {string} Postgres database name
DB_USER # {string} Database user
DB_PASSWORD # {string} Database password
DB_PORT # {number} Database port
SLOT_NAME # {string} A unique name for Postgres to track where this server has "listened until". If the server dies, it can pick up from the last position. This should be lowercase.
PORT # {number} Port which you can connect your client/listeners

DB_RETRY_INITIAL_DELAY # {number} Database connection retry initial delay in milliseconds. Default is 500.
DB_RETRY_MAXIMUM_DELAY # {number} Database connection retry maximum delay in milliseconds. Default is 300000 (5 minutes).
DB_RETRY_JITTER # {number} Database connection retry jitter in milliseconds. Default is 10 (10%).

SECURE_CHANNELS # {boolean} (true/false) Enable/Disable channels authorization via JWT verification.
JWT_SECRET # {string} HS algorithm octet key (e.g. "95x0oR8jq9unl9pOIx"). Only required if SECURE_CHANNELS is set to true.
JWT_CLAIM_VALIDATORS # {JSON object} Claim key and expected claim value pairs compared (equality checks) to JWT claims in order to validate JWT. e.g. {'iss': 'Issuer', 'nbf': 1610078130}. This is optional but encouraged.
```

### Channels Authorization

Channels connections are authorized via JWT verification. Only supports JWTs signed with the following algorithms:
- HS256
- HS384
- HS512

Verify JWT claims by setting JWT_CLAIM_VALIDATORS:

> e.g. {'iss': 'Issuer', 'nbf': 1610078130}
>
> Then JWT's "iss" value must equal "Issuer" and "nbf" value must equal 1610078130.
**NOTE:** JWT expiration is checked automatically.

**Development**: Channels are not secure by default. Set SECURE_CHANNELS to `true` to test JWT verification locally.

**Production**: Channels are secure by default and you must set JWT_SECRET. Set SECURE_CHANNELS to `false` to proceed without checking authorization.

## Contributing

- Fork the repo on GitHub
Expand Down
26 changes: 23 additions & 3 deletions server/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,31 @@ db_user = System.get_env("DB_USER", "postgres")
db_password = System.get_env("DB_PASSWORD", "postgres")
# HACK: There's probably a better way to set boolean from env
db_ssl = System.get_env("DB_SSL", "true") === "true"
slot_name = System.get_env("SLOT_NAME") || :temporary
configuration_file = System.get_env("CONFIGURATION_FILE") || nil

# Initial delay defaults to half a second
db_retry_initial_delay = System.get_env("DB_RETRY_INITIAL_DELAY", "500")
# Maximum delay defaults to five minutes
db_retry_maximum_delay = System.get_env("DB_RETRY_MAXIMUM_DELAY", "300000")
# Jitter will randomly adjust each delay within 10% of its value
db_retry_jitter = System.get_env("DB_RETRY_JITTER", "10")
slot_name = System.get_env("SLOT_NAME") || :temporary
configuration_file = System.get_env("CONFIGURATION_FILE") || nil

# Channels are not secured by default in development and
# are secured by default in production.
secure_channels = System.get_env("SECURE_CHANNELS", "true") != "false"

# Supports HS algorithm octet keys
# e.g. "95x0oR8jq9unl9pOIx"
jwt_secret = System.get_env("JWT_SECRET", "")

# Every JWT's claims will be compared (equality checks) to the expected
# claims set in the JSON object.
# e.g.
# Set JWT_CLAIM_VALIDATORS="{'iss': 'Issuer', 'nbf': 1610078130}"
# Then JWT's "iss" value must equal "Issuer" and "nbf" value
# must equal 1610078130.
jwt_claim_validators = System.get_env("JWT_CLAIM_VALIDATORS", "{}")

config :realtime,
app_hostname: app_hostname,
Expand All @@ -40,7 +57,10 @@ config :realtime,
db_retry_maximum_delay: db_retry_maximum_delay,
db_retry_jitter: db_retry_jitter,
slot_name: slot_name,
configuration_file: configuration_file
configuration_file: configuration_file,
secure_channels: secure_channels,
jwt_secret: jwt_secret,
jwt_claim_validators: jwt_claim_validators

# Configures the endpoint
config :realtime, RealtimeWeb.Endpoint,
Expand Down
8 changes: 8 additions & 0 deletions server/config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ use Mix.Config
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources.

# Channels are not secured by default in development and
# are secured by default in production.
secure_channels = System.get_env("SECURE_CHANNELS", "false") == "true"

config :realtime,
secure_channels: secure_channels

config :realtime, RealtimeWeb.Endpoint,
http: [port: 4000],
debug_errors: true,
Expand Down
31 changes: 30 additions & 1 deletion server/config/releases.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ db_ssl = System.get_env("DB_SSL", "true") === "true"
slot_name = System.get_env("SLOT_NAME") || :temporary
configuration_file = System.get_env("CONFIGURATION_FILE")

# Initial delay defaults to half a second
db_retry_initial_delay = System.get_env("DB_RETRY_INITIAL_DELAY", "500")
# Maximum delay defaults to five minutes
db_retry_maximum_delay = System.get_env("DB_RETRY_MAXIMUM_DELAY", "300000")
# Jitter will randomly adjust each delay within 10% of its value
db_retry_jitter = System.get_env("DB_RETRY_JITTER", "10")

# Channels are not secured by default in development and
# are secured by default in production.
secure_channels = System.get_env("SECURE_CHANNELS", "true") != "false"

# Supports HS algorithm octet keys
# e.g. "95x0oR8jq9unl9pOIx"
jwt_secret = System.get_env("JWT_SECRET", "")

# Every JWT's claims will be compared (equality checks) to the expected
# claims set in the JSON object.
# e.g.
# Set JWT_CLAIM_VALIDATORS="{'iss': 'Issuer', 'nbf': 1610078130}"
# Then JWT's "iss" value must equal "Issuer" and "nbf" value
# must equal 1610078130.
jwt_claim_validators = System.get_env("JWT_CLAIM_VALIDATORS", "{}")

config :realtime,
app_hostname: app_hostname,
app_port: app_port,
Expand All @@ -23,8 +46,14 @@ config :realtime,
db_user: db_user,
db_password: db_password,
db_ssl: db_ssl,
db_retry_initial_delay: db_retry_initial_delay,
db_retry_maximum_delay: db_retry_maximum_delay,
db_retry_jitter: db_retry_jitter,
slot_name: slot_name,
configuration_file: configuration_file
configuration_file: configuration_file,
secure_channels: secure_channels,
jwt_secret: jwt_secret,
jwt_claim_validators: jwt_claim_validators

secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
Expand Down
6 changes: 6 additions & 0 deletions server/config/test.exs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
use Mix.Config

config :realtime,
secure_channels: false

# We don't run a server during test. If one is required,
# you can enable the server option below.
config :realtime, RealtimeWeb.Endpoint,
http: [port: 4002],
server: false

config :joken,
current_time_adapter: RealtimeWeb.Joken.CurrentTime.Mock

# Print only warnings and errors during test
config :logger, level: :warn
18 changes: 18 additions & 0 deletions server/lib/realtime/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ defmodule Realtime.Application do
use Application
require Logger, warn: false

defmodule JwtSecretError, do: defexception([:message])
defmodule JwtClaimValidatorsError, do: defexception([:message])

def start(_type, _args) do
# Hostname must be a char list for some reason
# Use this var to convert to sigil at connection
Expand Down Expand Up @@ -33,6 +36,21 @@ defmodule Realtime.Application do
db_retry_maximum_delay = Application.fetch_env!(:realtime, :db_retry_maximum_delay)
db_retry_jitter = Application.fetch_env!(:realtime, :db_retry_jitter)

if Application.fetch_env!(:realtime, :secure_channels) do
if Application.fetch_env!(:realtime, :jwt_secret) == "" do
raise JwtSecretError, message: "JWT secret is missing"
end

case Application.fetch_env!(:realtime, :jwt_claim_validators) |> Jason.decode() do
{:ok, claims} when is_map(claims) ->
Application.put_env(:realtime, :jwt_claim_validators, claims)

_ ->
raise JwtClaimValidatorsError,
message: "JWT claim validators is not a valid JSON object"
end
end

# List all child processes to be supervised
children = [
# Start the endpoint when the application starts
Expand Down
15 changes: 15 additions & 0 deletions server/lib/realtime_web/channels/auth/channels_authorization.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule RealtimeWeb.ChannelsAuthorization do
alias RealtimeWeb.JwtVerification

def authorize(token) when is_binary(token) do
token
|> clean_token()
|> JwtVerification.verify()
end

def authorize(_token), do: :error

defp clean_token(token) do
Regex.replace(~r/\s|\n/, URI.decode(token), "")
end
end
60 changes: 60 additions & 0 deletions server/lib/realtime_web/channels/auth/jwt_verification.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule RealtimeWeb.JwtVerification do
defmodule JwtAuthToken do
use Joken.Config

@impl true
def token_config do
Application.fetch_env!(:realtime, :jwt_claim_validators)
|> Enum.reduce(%{}, fn {claim_key, expected_val}, claims ->
add_claim_validator(claims, claim_key, expected_val)
end)
|> add_claim_validator("exp")
end

defp add_claim_validator(claims, "exp") do
add_claim(claims, "exp", nil, &(&1 > current_time()))
end

defp add_claim_validator(claims, claim_key, expected_val) do
add_claim(claims, claim_key, nil, &(&1 == expected_val))
end
end

@hs_algorithms ["HS256", "HS384", "HS512"]

def verify(token) when is_binary(token) do
with :ok <- check_claims_format(token),
{:ok, header} <- check_header_format(token),
{:ok, signer} <- generate_signer(header) do
JwtAuthToken.verify_and_validate(token, signer)
else
_ -> :error
end
end

def verify(_token), do: :error

defp check_header_format(token) do
case Joken.peek_header(token) do
{:ok, header} when is_map(header) -> {:ok, header}
_ -> :error
end
end

defp check_claims_format(token) do
case Joken.peek_claims(token) do
{:ok, claims} when is_map(claims) -> :ok
_ -> :error
end
end

defp generate_signer(%{"typ" => "JWT", "alg" => alg}) when alg in @hs_algorithms do
{:ok,
Joken.Signer.create(
alg,
Application.fetch_env!(:realtime, :jwt_secret)
)}
end

defp generate_signer(_header), do: :error
end
14 changes: 2 additions & 12 deletions server/lib/realtime_web/channels/realtime_channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,8 @@ defmodule RealtimeWeb.RealtimeChannel do
use RealtimeWeb, :channel
require Logger, warn: false

# Add authorization logic here as required.
defp authorized?(_payload) do
true
end

def join("realtime:" <> topic, payload, socket) do
if authorized?(payload) do
{:ok, %{}, socket}
else
{:error, %{reason: "unauthorized"}}
end
def join("realtime:" <> _topic, _payload, socket) do
{:ok, %{}, socket}
end

@doc """
Expand All @@ -24,7 +15,6 @@ defmodule RealtimeWeb.RealtimeChannel do
# {:noreply, socket}
# end


@doc """
Handles a full, decoded transation.
"""
Expand Down
20 changes: 18 additions & 2 deletions server/lib/realtime_web/channels/user_socket.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule RealtimeWeb.UserSocket do
use Phoenix.Socket

alias RealtimeWeb.ChannelsAuthorization

## Channels
channel "realtime:*", RealtimeWeb.RealtimeChannel

Expand All @@ -15,8 +17,12 @@ defmodule RealtimeWeb.UserSocket do
#
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
def connect(_params, socket, _connect_info) do
{:ok, socket}
def connect(params, socket) do
case Application.fetch_env!(:realtime, :secure_channels)
|> authorize_conn(params) do
:ok -> {:ok, socket}
_ -> :error
end
end

# Socket id's are topics that allow you to identify all sockets for a given user:
Expand All @@ -30,4 +36,14 @@ defmodule RealtimeWeb.UserSocket do
#
# Returning `nil` makes this socket anonymous.
def id(_socket), do: nil

defp authorize_conn(true, %{"token" => token}) do
case ChannelsAuthorization.authorize(token) do
{:ok, _} -> :ok
_ -> :error
end
end

defp authorize_conn(true, _params), do: :error
defp authorize_conn(false, _params), do: :ok
end
3 changes: 2 additions & 1 deletion server/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ defmodule Realtime.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:gettext, "~> 0.11"},
{:httpoison, "~> 1.6"},
{:jason, "~> 1.0"},
{:jason, "~> 1.2.2"},
{:joken, "~> 2.3.0"},
{:plug_cowboy, "~> 2.0"},
{:epgsql, "~> 4.2"},
{:retry, "~> 0.14"},
Expand Down
4 changes: 3 additions & 1 deletion server/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"},
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"},
"jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
Expand Down
Loading

0 comments on commit 0a288e0

Please sign in to comment.