From 0a288e0b803f075ce3a39939341464c600635615 Mon Sep 17 00:00:00 2001 From: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com> Date: Sun, 10 Jan 2021 18:46:45 -0500 Subject: [PATCH] feat: Add channels auth via JWT verification (#99) --- README.md | 45 ++++- server/config/config.exs | 26 ++- server/config/dev.exs | 8 + server/config/releases.exs | 31 ++- server/config/test.exs | 6 + server/lib/realtime/application.ex | 18 ++ .../channels/auth/channels_authorization.ex | 15 ++ .../channels/auth/jwt_verification.ex | 60 ++++++ .../realtime_web/channels/realtime_channel.ex | 14 +- .../lib/realtime_web/channels/user_socket.ex | 20 +- server/mix.exs | 3 +- server/mix.lock | 4 +- .../auth/channels_authorization_test.exs | 30 +++ .../channels/auth/jwt_verification_test.exs | 191 ++++++++++++++++++ .../channels/user_socket_test.exs | 37 ++++ .../test/support/joken_current_time_mock.ex | 56 +++++ 16 files changed, 537 insertions(+), 27 deletions(-) create mode 100644 server/lib/realtime_web/channels/auth/channels_authorization.ex create mode 100644 server/lib/realtime_web/channels/auth/jwt_verification.ex create mode 100644 server/test/realtime_web/channels/auth/channels_authorization_test.exs create mode 100644 server/test/realtime_web/channels/auth/jwt_verification_test.exs create mode 100644 server/test/realtime_web/channels/user_socket_test.exs create mode 100644 server/test/support/joken_current_time_mock.ex diff --git a/README.md b/README.md index c7d566722..9b8933429 100644 --- a/README.md +++ b/README.md @@ -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.
@@ -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) @@ -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 diff --git a/server/config/config.exs b/server/config/config.exs index 01e3e807f..57c607337 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -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, @@ -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, diff --git a/server/config/dev.exs b/server/config/dev.exs index e050d6557..d243de453 100644 --- a/server/config/dev.exs +++ b/server/config/dev.exs @@ -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, diff --git a/server/config/releases.exs b/server/config/releases.exs index 604392074..3a6944243 100644 --- a/server/config/releases.exs +++ b/server/config/releases.exs @@ -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, @@ -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") || diff --git a/server/config/test.exs b/server/config/test.exs index fb8ad7cb4..3553ab512 100644 --- a/server/config/test.exs +++ b/server/config/test.exs @@ -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 diff --git a/server/lib/realtime/application.ex b/server/lib/realtime/application.ex index 7854c5442..f32b71c9a 100644 --- a/server/lib/realtime/application.ex +++ b/server/lib/realtime/application.ex @@ -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 @@ -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 diff --git a/server/lib/realtime_web/channels/auth/channels_authorization.ex b/server/lib/realtime_web/channels/auth/channels_authorization.ex new file mode 100644 index 000000000..ff55c908b --- /dev/null +++ b/server/lib/realtime_web/channels/auth/channels_authorization.ex @@ -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 diff --git a/server/lib/realtime_web/channels/auth/jwt_verification.ex b/server/lib/realtime_web/channels/auth/jwt_verification.ex new file mode 100644 index 000000000..1ba3300f7 --- /dev/null +++ b/server/lib/realtime_web/channels/auth/jwt_verification.ex @@ -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 diff --git a/server/lib/realtime_web/channels/realtime_channel.ex b/server/lib/realtime_web/channels/realtime_channel.ex index fe370bfa3..c926d7e63 100644 --- a/server/lib/realtime_web/channels/realtime_channel.ex +++ b/server/lib/realtime_web/channels/realtime_channel.ex @@ -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 """ @@ -24,7 +15,6 @@ defmodule RealtimeWeb.RealtimeChannel do # {:noreply, socket} # end - @doc """ Handles a full, decoded transation. """ diff --git a/server/lib/realtime_web/channels/user_socket.ex b/server/lib/realtime_web/channels/user_socket.ex index 1e18bd568..b784669a8 100644 --- a/server/lib/realtime_web/channels/user_socket.ex +++ b/server/lib/realtime_web/channels/user_socket.ex @@ -1,6 +1,8 @@ defmodule RealtimeWeb.UserSocket do use Phoenix.Socket + alias RealtimeWeb.ChannelsAuthorization + ## Channels channel "realtime:*", RealtimeWeb.RealtimeChannel @@ -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: @@ -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 diff --git a/server/mix.exs b/server/mix.exs index 2e24f7960..052f5a26a 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -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"}, diff --git a/server/mix.lock b/server/mix.lock index e09c72c5f..29b6add5a 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -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"}, diff --git a/server/test/realtime_web/channels/auth/channels_authorization_test.exs b/server/test/realtime_web/channels/auth/channels_authorization_test.exs new file mode 100644 index 000000000..da7cba601 --- /dev/null +++ b/server/test/realtime_web/channels/auth/channels_authorization_test.exs @@ -0,0 +1,30 @@ +defmodule RealtimeWeb.ChannelsAuthorizationTest do + use ExUnit.Case + + import Mock + + alias RealtimeWeb.{ChannelsAuthorization, JwtVerification} + + test "authorize/1 when token is authorized" do + input_token = "\n token %20 1 %20 2 %20 3 " + expected_token = "token123" + + with_mock JwtVerification, + verify: fn token -> + assert token == expected_token + {:ok, %{}} + end do + assert {:ok, %{}} = ChannelsAuthorization.authorize(input_token) + end + end + + test "authorize/1 when token is unauthorized" do + with_mock JwtVerification, verify: fn _token -> {:error, "unauthorized"} end do + assert {:error, "unauthorized"} = ChannelsAuthorization.authorize("bad_token9") + end + end + + test "authorize/1 when token is not a string" do + assert :error = ChannelsAuthorization.authorize([]) + end +end diff --git a/server/test/realtime_web/channels/auth/jwt_verification_test.exs b/server/test/realtime_web/channels/auth/jwt_verification_test.exs new file mode 100644 index 000000000..07199d9ed --- /dev/null +++ b/server/test/realtime_web/channels/auth/jwt_verification_test.exs @@ -0,0 +1,191 @@ +defmodule RealtimeWeb.JwtVerificationTest do + use ExUnit.Case + + alias RealtimeWeb.JwtVerification + alias RealtimeWeb.Joken.CurrentTime.Mock + + @jwt_secret "secret" + @alg "HS256" + + setup_all do + Application.put_env(:realtime, :jwt_secret, @jwt_secret) + Application.put_env(:realtime, :jwt_claim_validators, %{}) + end + + setup do + {:ok, _pid} = start_supervised(Mock) + :ok + end + + test "verify/1 when token is not a string" do + assert :error = JwtVerification.verify([]) + end + + test "verify/1 when token has invalid format" do + invalid_token = Base.encode64("{}") + + assert :error = JwtVerification.verify(invalid_token) + end + + test "verify/1 when token header is not a map" do + invalid_token = + Base.encode64("[]") <> "." <> Base.encode64("{}") <> "." <> Base.encode64("<<\"sig\">>") + + assert :error = JwtVerification.verify(invalid_token) + end + + test "verify/1 when token claims is not a map" do + invalid_token = + Base.encode64("{}") <> "." <> Base.encode64("[]") <> "." <> Base.encode64("<<\"sig\">>") + + assert :error = JwtVerification.verify(invalid_token) + end + + test "verify/1 when token header does not have typ or alg" do + invalid_token = + Base.encode64("{\"typ\": \"JWT\"}") <> + "." <> Base.encode64("{}") <> "." <> Base.encode64("<<\"sig\">>") + + assert :error = JwtVerification.verify(invalid_token) + + invalid_token = + Base.encode64("{\"alg\": \"HS256\"}") <> + "." <> Base.encode64("{}") <> "." <> Base.encode64("<<\"sig\">>") + + assert :error = JwtVerification.verify(invalid_token) + end + + test "verify/1 when token header alg is not allowed" do + invalid_token = + Base.encode64("{\"typ\": \"JWT\", \"alg\": \"ZZ999\"}") <> + "." <> Base.encode64("{}") <> "." <> Base.encode64("<<\"sig\">>") + + assert :error = JwtVerification.verify(invalid_token) + end + + test "verify/1 when token is valid and alg is HS256" do + signer = Joken.Signer.create("HS256", @jwt_secret) + + token = Joken.generate_and_sign!(%{}, %{}, signer) + + assert {:ok, _claims} = JwtVerification.verify(token) + end + + test "verify/1 when token is valid and alg is HS384" do + signer = Joken.Signer.create("HS384", @jwt_secret) + + token = Joken.generate_and_sign!(%{}, %{}, signer) + + assert {:ok, _claims} = JwtVerification.verify(token) + end + + test "verify/1 when token is valid and alg is HS512" do + signer = Joken.Signer.create("HS512", @jwt_secret) + + token = Joken.generate_and_sign!(%{}, %{}, signer) + + assert {:ok, _claims} = JwtVerification.verify(token) + end + + test "verify/1 when token has expired" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + current_time = 1_610_086_801 + Mock.freeze(current_time) + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time end} + }, + %{}, + signer + ) + + assert {:error, [message: "Invalid token", claim: "exp", claim_val: 1_610_086_801]} = + JwtVerification.verify(token) + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time - 1 end} + }, + %{}, + signer + ) + + assert {:error, [message: "Invalid token", claim: "exp", claim_val: 1_610_086_800]} = + JwtVerification.verify(token) + end + + test "verify/1 when token has not expired" do + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time + 1 end} + }, + %{}, + signer + ) + + assert {:ok, _claims} = JwtVerification.verify(token) + end + + test "verify/1 when token claims match expected claims from :jwt_claim_validators config" do + Application.put_env(:realtime, :jwt_claim_validators, %{ + "iss" => "Tester", + "aud" => "www.test.com" + }) + + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time + 1 end}, + "iss" => %Joken.Claim{generate: fn -> "Tester" end}, + "aud" => %Joken.Claim{generate: fn -> "www.test.com" end}, + "sub" => %Joken.Claim{generate: fn -> "tester@test.com" end} + }, + %{}, + signer + ) + + assert {:ok, _claims} = JwtVerification.verify(token) + end + + test "verify/1 when token claims do not match expected claims from :jwt_claim_validators config" do + Application.put_env(:realtime, :jwt_claim_validators, %{ + "iss" => "Issuer", + "aud" => "www.test.com" + }) + + signer = Joken.Signer.create(@alg, @jwt_secret) + + Mock.freeze() + current_time = Mock.current_time() + + token = + Joken.generate_and_sign!( + %{ + "exp" => %Joken.Claim{generate: fn -> current_time + 1 end}, + "iss" => %Joken.Claim{generate: fn -> "Tester" end}, + "aud" => %Joken.Claim{generate: fn -> "www.test.com" end}, + "sub" => %Joken.Claim{generate: fn -> "tester@test.com" end} + }, + %{}, + signer + ) + + assert {:error, [message: "Invalid token", claim: "iss", claim_val: "Tester"]} = + JwtVerification.verify(token) + end +end diff --git a/server/test/realtime_web/channels/user_socket_test.exs b/server/test/realtime_web/channels/user_socket_test.exs new file mode 100644 index 000000000..41a53b89d --- /dev/null +++ b/server/test/realtime_web/channels/user_socket_test.exs @@ -0,0 +1,37 @@ +defmodule RealtimeWeb.UserSocketTest do + use RealtimeWeb.ChannelCase + + import Mock + + alias Phoenix.Socket + alias RealtimeWeb.{UserSocket, ChannelsAuthorization} + + test "connect/2 when :secure_channels config is false" do + Application.put_env(:realtime, :secure_channels, false) + + assert {:ok, %Socket{}} = UserSocket.connect(%{}, socket(UserSocket)) + end + + test "connect/2 when :secure_channels config is true and token is authorized" do + with_mock ChannelsAuthorization, authorize: fn _token -> {:ok, %{}} end do + Application.put_env(:realtime, :secure_channels, true) + + assert {:ok, %Socket{}} = + UserSocket.connect(%{"token" => "auth_token123"}, socket(UserSocket)) + end + end + + test "connect/2 when :secure_channels config is true and token is unauthorized" do + with_mock ChannelsAuthorization, authorize: fn _token -> {:error, "unauthorized"} end do + Application.put_env(:realtime, :secure_channels, true) + + assert :error = UserSocket.connect(%{"token" => "bad_token9"}, socket(UserSocket)) + end + end + + test "connect/2 when :secure_channels config is true and token is missing" do + Application.put_env(:realtime, :secure_channels, true) + + assert :error = UserSocket.connect(%{}, socket(UserSocket)) + end +end diff --git a/server/test/support/joken_current_time_mock.ex b/server/test/support/joken_current_time_mock.ex new file mode 100644 index 000000000..0c6950afd --- /dev/null +++ b/server/test/support/joken_current_time_mock.ex @@ -0,0 +1,56 @@ +defmodule RealtimeWeb.Joken.CurrentTime.Mock do + @moduledoc """ + + Mock implementation of Joken current time with time freezing. + + This is a copy of Joken.CurrentTime.Mock. + + """ + + use Agent + + def start_link(name) do + Agent.start_link( + fn -> + %{is_frozen: false, frozen_value: nil} + end, + name: name + ) + end + + def child_spec(_args) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [unique_name_per_process()]} + } + end + + def current_time do + state = Agent.get(unique_name_per_process(), fn state -> state end) + + if state[:is_frozen] do + state[:frozen_value] + else + :os.system_time(:second) + end + end + + def freeze do + freeze(:os.system_time(:second)) + end + + def freeze(timestamp) do + Agent.update(unique_name_per_process(), fn _state -> + %{is_frozen: true, frozen_value: timestamp} + end) + end + + def unique_name_per_process do + binary_pid = + self() + |> :erlang.pid_to_list() + |> :erlang.iolist_to_binary() + + "{__MODULE__}_#{binary_pid}" |> String.to_atom() + end +end