From 2a36799a055cc0e0480102655ba543808108aec2 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 24 Nov 2022 11:46:40 +0000 Subject: [PATCH] Update auth configuration to match provisioning (#66) * Update JWT auth configuration to match console * make integration tests docker compose v2 compatible * use new instance and regional ids * validate the length of the jwt key * add function to easily generate a token * start documentation of env variables and add a mix task to more easily generate authentication tokens * fix spec of format_tokens * commit to try to fix PR tests * add test for generate token task and tweak api * update instance and regional id config * fix tests * change pattern for successful start of vaxine * match both styles of app-up message why are the two so different? * try out-of-order match * add empty default because these are optional * emacs has a "format markdown table" function! * include global cluster id and make dev defaults mirror test defaults --- README.md | 69 +++++++--- config/config.exs | 5 +- config/dev.exs | 29 ++++- config/runtime.exs | 22 +--- config/test.exs | 13 +- integration_tests/common.mk | 1 + integration_tests/migrations/shared.luxinc | 2 +- integration_tests/multi_dc/Makefile | 1 + integration_tests/multi_dc/electric.template | 4 + integration_tests/multi_dc/shared.luxinc | 4 +- integration_tests/single_dc/electric.exs | 6 +- integration_tests/single_dc/electric_b.exs | 8 +- integration_tests/single_dc/shared.luxinc | 2 +- integration_tests/single_dc/sysbench_ws.lux | 9 +- lib/electric.ex | 18 ++- lib/electric/satellite/auth/jwt.ex | 96 +++++--------- lib/electric/satellite/satellite_auth.ex | 4 +- lib/electric/satellite/satellite_protocol.ex | 2 +- lib/electric/satellite_ws_server.ex | 2 +- lib/mix/tasks/electric.gen.token.ex | 118 ++++++++++++++++++ test/electric/satellite/satellite_ws_test.exs | 14 +-- test/electric_test.exs | 8 +- test/mix/tasks/electric.gen.token_test.exs | 102 +++++++++++++++ 23 files changed, 405 insertions(+), 134 deletions(-) create mode 100644 lib/mix/tasks/electric.gen.token.ex create mode 100644 test/mix/tasks/electric.gen.token_test.exs diff --git a/README.md b/README.md index 259420e5..ed930fb4 100644 --- a/README.md +++ b/README.md @@ -71,23 +71,58 @@ make stop_dev_env The Electric application is configured using environment variables. Everything that doesn't have a default is required to run. -| Variable | Default | Description | -| --- | --- | --- | -| `VAXINE_HOST` | | Host of Vaxine instance to connect to | -| `VAXINE_API_PORT` | `8087` | Port for the regular DB API on Vaxine instance | -| `VAXINE_REPLICATION_PORT` | `8088` | Port for the replication API on Vaxine instance | -| `VAXINE_CONNECTION_TIMEOUT` | `5000` | (ms) Timeout waiting while connecting to a Vaxine instance | -| | -| `ELECTRIC_HOST` | | Host of this electric instance for the reverse connection from Postgres. It has to be accessible from postgres instances listed in the `CONNECTORS` | -| `CONNECTORS` | `""` | Semicolon-separated list of Postgres connection strings for PG instances that will be part of the cluster | -| | -| `POSTGRES_REPLICATION_PORT` | `5433` | Port for connections from PG instances as replication followers | -| `STATUS_PORT` | `5050` | Port to expose health and status API endpoint | -| `WEBSOCKET_PORT` | `5133` | Port to expose the `/ws` path for the replication over the websocket | -| | -| `OFFSET_STORAGE_FILE` | `./offset_storage_data.dat` | Path to the file storing the mapping between connected instances and offsets in Vaxine WAL. Should be persisted between Electric restarts. | -| `MIGRATIONS_DIR` | | Directory to read the migration SQL files from | -| `MIGRATIONS_FILE_NAME_SUFFIX` | `/postgres.sql` | Suffix that is appended to the migration name when looking for the migration file | +| Variable | Default | Description | +|-------------------------------|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `VAXINE_HOST` | | Host of Vaxine instance to connect to | +| `VAXINE_API_PORT` | `8087` | Port for the regular DB API on Vaxine instance | +| `VAXINE_REPLICATION_PORT` | `8088` | Port for the replication API on Vaxine instance | +| `VAXINE_CONNECTION_TIMEOUT` | `5000` | (ms) Timeout waiting while connecting to a Vaxine instance | +| | | | +| `ELECTRIC_HOST` | | Host of this electric instance for the reverse connection from Postgres. It has to be accessible from postgres instances listed in the `CONNECTORS` | +| `CONNECTORS` | `""` | Semicolon-separated list of Postgres connection strings for PG instances that will be part of the cluster | +| | | | +| `POSTGRES_REPLICATION_PORT` | `5433` | Port for connections from PG instances as replication followers | +| `STATUS_PORT` | `5050` | Port to expose health and status API endpoint | +| `WEBSOCKET_PORT` | `5133` | Port to expose the `/ws` path for the replication over the websocket | +| | | | +| `OFFSET_STORAGE_FILE` | `./offset_storage_data.dat` | Path to the file storing the mapping between connected instances and offsets in Vaxine WAL. Should be persisted between Electric restarts. | +| | | | +| `MIGRATIONS_DIR` | | Directory to read the migration SQL files from (see below) | +| `MIGRATIONS_FILE_NAME_SUFFIX` | `/postgres.sql` | Suffix that is appended to the migration name when looking for the migration file | +| | | | +| `SATELLITE_AUTH_SIGNING_KEY` | `""` | Authentication token signing/validation secret key. See below. | +| `SATELLITE_AUTH_SIGNING_ISS` | `""` | Cluster ID which acts as the issuer for the authentication JWT. See below. | + +**Authentication** + +By default, in dev mode, electric uses insecure authentication. This just +accepts a user id as the authentication token and authorizes the connection as +that user. + +Token based authentication requires a signed JWT token with a `user_id` claim, +and a valid issuer. + +To turn on token-based authentication in dev mode and when running in +production, set the following environment variables: + +- `SATELLITE_AUTH_SIGNING_KEY` - Some random string used as the HMAC signing + key. Must be at least 32 bytes long. + +- `SATELLITE_AUTH_SIGNING_ISS` - The JWT issuer (the `iss` field in the JWT). + +You can generate a valid token using these configuration values by running `mix electric.gen.token`, e.g: + +``` shell +$ export SATELLITE_AUTH_SIGNING_KEY=00000000000000000000000000000000 +$ export SATELLITE_AUTH_SIGNING_ISS=my.electric.server +$ mix electric.gen.token my_user my_other_user +``` + +The generated token(s) must be passed in the `token` field of the `SatAuthReq` +protocol message. + +For them to work, you must run the electric server configured with the same +`SATELLITE_AUTH_SIGNING_KEY` and `SATELLITE_AUTH_SIGNING_ISS` set. ## Migrations diff --git a/config/config.exs b/config/config.exs index 0db0a7d7..d30777a1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,7 +20,8 @@ config :logger, :console, :sq_client, :vx_consumer, :vx_producer, - :cluster_id, + :instance_id, + :regional_id, :client_id, :user_id ] @@ -40,4 +41,4 @@ config :electric, Electric.Satellite.Auth, provider: {Electric.Satellite.Auth.In # 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" +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index f7ed589e..b7bc201f 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -73,8 +73,33 @@ config :electric, Electric.Replication.SQConnectors, vaxine_connection_timeout: 5000 config :electric, - global_cluster_id: System.get_env("GLOBAL_CLUSTER_ID", "electric-development-cluster-0000") + global_cluster_id: System.get_env("GLOBAL_CLUSTER_ID", "dev.electric-db"), + instance_id: System.get_env("ELECTRIC_INSTANCE_ID", "instance-1.region-1.dev.electric-db"), + regional_id: System.get_env("ELECTRIC_REGIONAL_ID", "region-1.dev.electric-db") config :logger, level: :debug -config :electric, Electric.Satellite.Auth, provider: {Electric.Satellite.Auth.Insecure, []} +auth_provider = + with {:ok, auth_key} <- System.fetch_env("SATELLITE_AUTH_SIGNING_KEY"), + {:ok, auth_iss} <- System.fetch_env("SATELLITE_AUTH_SIGNING_ISS") do + IO.puts("using JWT auth for issuer #{auth_iss}") + + if byte_size(auth_key) >= 32 do + {Electric.Satellite.Auth.JWT, issuer: auth_iss, secret_key: auth_key} + else + IO.puts( + IO.ANSI.format([ + :bright, + :red, + "SATELLITE_AUTH_SIGNING_KEY value needs to be 32 bytes or greater. Falling back to insecure auth" + ]) + ) + + {Electric.Satellite.Auth.Insecure, []} + end + else + :error -> + {Electric.Satellite.Auth.Insecure, []} + end + +config :electric, Electric.Satellite.Auth, provider: auth_provider diff --git a/config/runtime.exs b/config/runtime.exs index 7eda41b4..908523db 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -86,24 +86,14 @@ if config_env() == :prod do dir: System.fetch_env!("MIGRATIONS_DIR"), migration_file_name_suffix: System.get_env("MIGRATIONS_FILE_NAME_SUFFIX", "/postgres.sql") - # set to the database.cluster_slug - global_cluster_id = System.fetch_env!("GLOBAL_CLUSTER_ID") - config :electric, - global_cluster_id: global_cluster_id + global_cluster_id: System.fetch_env!("GLOBAL_CLUSTER_ID"), + instance_id: System.fetch_env!("ELECTRIC_INSTANCE_ID"), + regional_id: System.fetch_env!("ELECTRIC_REGIONAL_ID") - # key = :crypto.strong_rand_bytes(32) |> Base.encode64() - auth_secret_key = System.fetch_env!("SATELLITE_AUTH_SIGNING_KEY") |> Base.decode64!() + auth_key = System.fetch_env!("SATELLITE_AUTH_SIGNING_KEY") + auth_iss = System.fetch_env!("SATELLITE_AUTH_SIGNING_ISS") - # 🐉 DANGER: this "issuer" configuration *MUST* be the same - # as the configuration in the console, currently under [:electric, :site_domain] - # I'm hard-coding this in all envs ATM for simplicity - # if these config values do not match, the jwt token verification *will fail* - # safe option is probably to just remove the `iss` field from the token config :electric, Electric.Satellite.Auth, - provider: - {Electric.Satellite.Auth.JWT, - issuer: "electric-sql.com", - secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ="), - global_cluster_id: global_cluster_id} + provider: {Electric.Satellite.Auth.JWT, issuer: auth_iss, secret_key: auth_key} end diff --git a/config/test.exs b/config/test.exs index ed21082a..2ca482c6 100644 --- a/config/test.exs +++ b/config/test.exs @@ -29,6 +29,13 @@ config :electric, Electric.Replication.SQConnectors, config :electric, Electric.Migrations, migration_file_name_suffix: "/postgres.sql" -config :electric, global_cluster_id: "electric-development-cluster-0000" - -config :electric, Electric.Satellite.Auth, provider: {Electric.Satellite.Auth.Insecure, []} +config :electric, + global_cluster_id: "test.electric-db", + instance_id: "instance-1.region-1.test.electric-db", + regional_id: "region-1.test.electric-db" + +config :electric, Electric.Satellite.Auth, + provider: + {Electric.Satellite.Auth.JWT, + issuer: "dev.electric-db", + secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ=")} diff --git a/integration_tests/common.mk b/integration_tests/common.mk index 9adc6f87..5c46acf4 100644 --- a/integration_tests/common.mk +++ b/integration_tests/common.mk @@ -5,6 +5,7 @@ DOCKER_REGISTRY=europe-docker.pkg.dev/vaxine/vaxine-io export ELIXIR_VERSION=1.13.4 export OTP_VERSION=24.3 export DEBIAN_VERSION=bullseye-20210902-slim +export COMPOSE_COMPATIBILITY=true export UID=$(shell id -u) export GID=$(shell id -g) diff --git a/integration_tests/migrations/shared.luxinc b/integration_tests/migrations/shared.luxinc index d9469e93..17565fb1 100644 --- a/integration_tests/migrations/shared.luxinc +++ b/integration_tests/migrations/shared.luxinc @@ -14,7 +14,7 @@ [shell vaxine] !make start_vaxine_1 - ?vx_server started + ?(application: vx_server)|(vx_server started) [shell vaxine_wait] [invoke wait_port localhost 8088] diff --git a/integration_tests/multi_dc/Makefile b/integration_tests/multi_dc/Makefile index 3cf1f4d4..750ae748 100644 --- a/integration_tests/multi_dc/Makefile +++ b/integration_tests/multi_dc/Makefile @@ -16,6 +16,7 @@ build: -e "s:{PG_PORT}:5432:g" \ -e "s:{EL_HOST}:electric_$${num}:g" \ -e "s:{EL_PORT}:5433:g" \ + -e "s:{REGION}:$${num}:g" \ < electric.template > electric_$$num.exs; \ done diff --git a/integration_tests/multi_dc/electric.template b/integration_tests/multi_dc/electric.template index 4b3f9ce3..af9e86a3 100644 --- a/integration_tests/multi_dc/electric.template +++ b/integration_tests/multi_dc/electric.template @@ -35,3 +35,7 @@ config :electric, Electric.Replication.Connectors, ] config :logger, backends: [:console], level: :debug + +config :electric, + instance_id: "instance-a.region-{REGION}.test.electric-db", + regional_id: "region-{REGION}.test.electric-db" diff --git a/integration_tests/multi_dc/shared.luxinc b/integration_tests/multi_dc/shared.luxinc index 358581b7..a1e49d9c 100644 --- a/integration_tests/multi_dc/shared.luxinc +++ b/integration_tests/multi_dc/shared.luxinc @@ -14,11 +14,11 @@ [shell vaxine_1] !make start_vaxine_1 - ?vx_server started + ?(application: vx_server)|(vx_server started) [shell vaxine_2] !make start_vaxine_2 - ?vx_server started + ?(application: vx_server)|(vx_server started) [shell vaxine_wait] [invoke wait_port localhost 8087] diff --git a/integration_tests/single_dc/electric.exs b/integration_tests/single_dc/electric.exs index f069c562..f4e7db94 100644 --- a/integration_tests/single_dc/electric.exs +++ b/integration_tests/single_dc/electric.exs @@ -71,11 +71,11 @@ config :electric, Electric.Replication.SQConnectors, config :logger, backends: [:console], level: :debug config :electric, - global_cluster_id: "fake-global-id-for-tests" + instance_id: "instance-a.region-1.test.electric-db", + regional_id: "region-1.test.electric-db" config :electric, Electric.Satellite.Auth, provider: {Electric.Satellite.Auth.JWT, issuer: "dev.electric-sql.com", - secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ="), - global_cluster_id: "fake-global-id-for-tests"} + secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ=")} diff --git a/integration_tests/single_dc/electric_b.exs b/integration_tests/single_dc/electric_b.exs index 81da3f18..8c6060ae 100644 --- a/integration_tests/single_dc/electric_b.exs +++ b/integration_tests/single_dc/electric_b.exs @@ -41,12 +41,12 @@ config :electric, Electric.Replication.SQConnectors, config :logger, backends: [:console], level: :debug -config :electric, Electric.Satellite, - global_cluster_id: "fake-global-id-for-tests" +config :electric, + instance_id: "instance-b.region-1.test.electric-db", + regional_id: "region-1.test.electric-db" config :electric, Electric.Satellite.Auth, provider: {Electric.Satellite.Auth.JWT, issuer: "dev.electric-sql.com", - secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ="), - global_cluster_id: "fake-global-id-for-tests"} + secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ=")} diff --git a/integration_tests/single_dc/shared.luxinc b/integration_tests/single_dc/shared.luxinc index 7967776f..cf23dd7b 100644 --- a/integration_tests/single_dc/shared.luxinc +++ b/integration_tests/single_dc/shared.luxinc @@ -14,7 +14,7 @@ [shell vaxine] !make start_vaxine_1 - ?vx_server started + ?(application: vx_server)|(vx_server started) [shell vaxine_wait] [invoke wait_port localhost 8088] diff --git a/integration_tests/single_dc/sysbench_ws.lux b/integration_tests/single_dc/sysbench_ws.lux index 7633d1f5..d72b1a41 100644 --- a/integration_tests/single_dc/sysbench_ws.lux +++ b/integration_tests/single_dc/sysbench_ws.lux @@ -43,9 +43,10 @@ [shell ws1] [invoke log "Start WS client and start consuming data"] [invoke start_elixir_test] + # issuer and secret key here must be the same as the issuer in the + # config [:electric, Electric.Satellite.Auth, :provider] !provider = {Electric.Satellite.Auth.JWT, issuer: "dev.electric-sql.com", \ - secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ="), \ - global_cluster_id: "fake-global-id-for-tests"} + secret_key: Base.decode64!("AgT/MeUiP3SKzw5gC6BZKXk4t1ulnUvZy2d/O73R0sQ=")} ?$eprompt !Electric.Test.SatelliteWsClient.connect_and_spawn( \ [ \ @@ -57,7 +58,7 @@ {:host, "electric_1"}, \ {:auto_ping, :true} \ ]) - ?$eprompt + ?+$eprompt ?(.*) %Electric.Satellite.SatInStartReplicationReq{__uf__: \[], lsn: "", (.*) [shell pg_2] @@ -159,7 +160,7 @@ # {:host, "electric_1"}, \ # {:auto_ping, :true} \ # ]) -# ?$eprompt +# ?+$eprompt # ?(.*) %Electric.Satellite.SatInStartReplicationReq{__uf__: \[], lsn: "3", (.*) [cleanup] diff --git a/lib/electric.ex b/lib/electric.ex index 80a2d86a..9c8528b3 100644 --- a/lib/electric.ex +++ b/lib/electric.ex @@ -65,10 +65,20 @@ defmodule Electric do @doc """ Every electric cluster belongs to a particular console database instance - This is that database instance slug + This is that database instance id """ - @spec global_cluster_id() :: binary | no_return - def global_cluster_id do - Application.fetch_env!(:electric, :global_cluster_id) + @spec instance_id() :: binary | no_return + def instance_id do + Application.fetch_env!(:electric, :instance_id) + end + + @doc """ + Identifier that's unique for every electric cluster instance. + + So basically region + instance_id + """ + @spec regional_id() :: binary | no_return + def regional_id do + Application.fetch_env!(:electric, :regional_id) end end diff --git a/lib/electric/satellite/auth/jwt.ex b/lib/electric/satellite/auth/jwt.ex index e16bce9b..0120f7c9 100644 --- a/lib/electric/satellite/auth/jwt.ex +++ b/lib/electric/satellite/auth/jwt.ex @@ -1,40 +1,15 @@ defmodule Electric.Satellite.Auth.JWT do alias Electric.Satellite.Auth + require Logger + @behaviour Auth defmodule Token do - @spec verify(binary, binary, binary) :: {:ok, %{binary => any}} | {:error, Keyword.t()} - def verify(global_cluster_id, token, shared_key, opts \\ []) do - with {:ok, key} <- signing_key(global_cluster_id, shared_key) do - params = %{key: key} - - params = - case Keyword.get(opts, :issuer) do - nil -> - params - - issuer when is_binary(issuer) -> - Map.put(params, :iss, issuer) - end - - JWT.verify(token, params) - end - end - - @spec signing_key(binary, binary) :: {:ok, binary} - defp signing_key(global_cluster_id, shared_key) do - key = - shared_key - |> hmac("EDBv01") - |> hmac(global_cluster_id) - - {:ok, key} - end - - @spec hmac(binary, binary) :: binary - defp hmac(key, data) when byte_size(key) == 32 do - :crypto.mac(:hmac, :sha256, key, data) + @spec verify(binary, binary, binary, Keyword.t()) :: + {:ok, %{binary => any}} | {:error, Keyword.t()} + def verify(token, key, iss, _opts \\ []) do + JWT.verify(token, %{key: key, iss: iss}) end # 15 mins @@ -43,16 +18,12 @@ defmodule Electric.Satellite.Auth.JWT do # For internal use only. Create a valid access token for this app @doc false @spec create(binary, binary, binary, Keyword.t()) :: {:ok, binary} | no_return - def create(global_cluster_id, user_id, shared_key, opts \\ []) do - {:ok, key} = signing_key(global_cluster_id, shared_key) - issuer = Keyword.get(opts, :issuer) - + def create(user_id, key, iss, opts \\ []) do nonce = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) custom_claims = %{ - "global_cluster_id" => global_cluster_id, "user_id" => user_id, "nonce" => nonce, "type" => "access" @@ -62,16 +33,10 @@ defmodule Electric.Satellite.Auth.JWT do token_opts = %{ alg: "HS256", - exp: expiry + exp: expiry, + iss: iss } - token_opts = - if issuer do - Map.put(token_opts, :iss, issuer) - else - token_opts - end - claims = Map.merge(custom_claims, token_opts) {:ok, JWT.sign(claims, %{key: key})} @@ -84,16 +49,17 @@ defmodule Electric.Satellite.Auth.JWT do @impl true def validate_token(token, config) do - {:ok, global_cluster_id} = Keyword.fetch(config, :global_cluster_id) {:ok, key} = Keyword.fetch(config, :secret_key) - opts = Keyword.take(config, [:issuer]) + {:ok, iss} = Keyword.fetch(config, :issuer) + Logger.debug(["Validating token for issuer: ", iss]) - with {:ok, claims} <- Token.verify(global_cluster_id, token, key, opts), - {:claims, - %{"global_cluster_id" => ^global_cluster_id, "user_id" => user_id, "type" => "access"}} <- - {:claims, claims} do + with {:ok, claims} <- Token.verify(token, key, iss, []), + {:claims, %{"user_id" => user_id, "type" => "access"}} <- {:claims, claims} do {:ok, %Auth{user_id: user_id}} else + {:claims, %{"type" => "refresh"}} -> + {:error, "refresh token not valid for authentication"} + {:claims, _claims} -> {:error, "invalid access token"} @@ -107,21 +73,27 @@ defmodule Electric.Satellite.Auth.JWT do @impl true def generate_token(user_id, config, opts) do - {:ok, global_cluster_id} = Keyword.fetch(config, :global_cluster_id) + {:ok, iss} = Keyword.fetch(config, :issuer) {:ok, key} = Keyword.fetch(config, :secret_key) - # allow the opts to override any configured issuer issuer is problematic as it must be - # identical between signer and verifier. not sure how that will play out in a multi-tenant - # auth system - opts = - case Keyword.get(config, :issuer) do - nil -> - opts + Token.create(user_id, key, iss, opts) + end - issuer when is_binary(issuer) -> - Keyword.put_new(opts, :issuer, issuer) - end + def generate_token(user_id, opts \\ []) do + with {__MODULE__, config} <- Electric.Satellite.Auth.provider() do + generate_token(user_id, config, opts) + else + {provider, _config} -> + {:error, "JWT authentication not configured, provider set to #{provider}"} + end + end - Token.create(global_cluster_id, user_id, key, opts) + def validate_token(token) do + with {__MODULE__, config} <- Electric.Satellite.Auth.provider() do + validate_token(token, config) + else + {provider, _config} -> + {:error, "JWT authentication not configured, provider set to #{provider}"} + end end end diff --git a/lib/electric/satellite/satellite_auth.ex b/lib/electric/satellite/satellite_auth.ex index 7317b42e..5b9c8569 100644 --- a/lib/electric/satellite/satellite_auth.ex +++ b/lib/electric/satellite/satellite_auth.ex @@ -19,7 +19,7 @@ defmodule Electric.Satellite.Auth do @type provider() :: {module, Access.t()} @type validate_resp() :: {:ok, t()} | {:error, :expired} | {:error, reason :: binary} - @doc "Validates the given token for the cluster given by `global_cluster_id` using the configuration provided" + @doc "Validates the given token against the configuration provided" @callback validate_token(token :: binary, config :: Access.t()) :: validate_resp() @doc "Creates a token for the given user id. Only really for testing purposes" @@ -41,7 +41,7 @@ defmodule Electric.Satellite.Auth do """ @spec provider() :: provider() | no_return def provider do - {:ok, config} = Application.fetch_env(:electric, Electric.Satellite.Auth) |> IO.inspect() + {:ok, config} = Application.fetch_env(:electric, Electric.Satellite.Auth) {:ok, {_module, _params} = provider} = Access.fetch(config, :provider) provider end diff --git a/lib/electric/satellite/satellite_protocol.ex b/lib/electric/satellite/satellite_protocol.ex index 367c6451..4ca21a86 100644 --- a/lib/electric/satellite/satellite_protocol.ex +++ b/lib/electric/satellite/satellite_protocol.ex @@ -150,7 +150,7 @@ defmodule Electric.Satellite.Protocol do Logger.metadata(client_id: client_id, user_id: auth.user_id) Logger.info("authenticated client #{client_id} as user #{auth.user_id}") - {%SatAuthResp{id: Electric.global_cluster_id()}, + {%SatAuthResp{id: Electric.regional_id()}, %State{state | auth: auth, auth_passed: true, client_id: client_id}} else {:error, :expired} -> diff --git a/lib/electric/satellite_ws_server.ex b/lib/electric/satellite_ws_server.ex index 5a52c1ea..6702bc98 100644 --- a/lib/electric/satellite_ws_server.ex +++ b/lib/electric/satellite_ws_server.ex @@ -62,7 +62,7 @@ defmodule Electric.Satellite.WsServer do # Add the cluster id to the logger metadata to make filtering easier in the case of global log # aggregation - Logger.metadata(cluster_id: Electric.global_cluster_id()) + Logger.metadata(instance_id: Electric.instance_id(), regional_id: Electric.regional_id()) {:cowboy_websocket, req, %State{ diff --git a/lib/mix/tasks/electric.gen.token.ex b/lib/mix/tasks/electric.gen.token.ex new file mode 100644 index 00000000..ce3ec298 --- /dev/null +++ b/lib/mix/tasks/electric.gen.token.ex @@ -0,0 +1,118 @@ +defmodule Mix.Tasks.Electric.Gen.Token do + use Mix.Task + + @shortdoc "Generate an authentication token" + @moduledoc """ + Generate an authentication token for the given user ids. + + This requires the application to be configured for JWT authentication with the required + environment variables set, i.e. `SATELLITE_AUTH_SIGNING_KEY` and `SATELLITE_AUTH_SIGNING_ISS`. + See the README under "Environment Variables". + + ## Usage + + mix electric.gen.token USER_ID + + Where `USER_ID` is the user id who you'd like to generate the token for. + + ## Command line options + + * `--ttl` - the time to life of the token, in seconds. Defaults to 1 year + + * `--format FORMAT` - choose output format, either `json` or `csv`. If no format is specified, + then output a human readable summary. + + * `--output` - write the token information to a file, not stdout + + If `--output` is specified then the default format will be one comma-separated user id, token + and expiry per line. + """ + + # these tokens are for testing so give them a long exipry + @default_ttl_seconds 3600 * 24 * 365 + @valid_formats ~w(json csv) + + def run(argv) do + Logger.configure_backend(:console, level: :error) + + {args, user_ids, _} = + OptionParser.parse(argv, strict: [ttl: :integer, format: :string, output: :string]) + + format = Keyword.get(args, :format, nil) + + if format && format not in @valid_formats do + Mix.Shell.IO.error("Invalid format '#{format}'") + System.halt(1) + end + + path = args[:output] + # if we're writing to a file, default to csv format + default_text_format = if path, do: "csv", else: "cli" + format = String.to_existing_atom(format || default_text_format) + ttl = Keyword.get(args, :ttl, @default_ttl_seconds) + expiry = DateTime.add(DateTime.utc_now(), ttl, :second) + + tokens = + for user_id <- user_ids do + case Electric.Satellite.Auth.JWT.generate_token(user_id, expiry: DateTime.to_unix(expiry)) do + {:ok, token} -> + %{token: token, user_id: user_id, expiry: expiry} + + {:error, reason} -> + Mix.Shell.IO.error(reason) + System.halt(1) + end + end + + output = format_tokens(tokens, format) + + if path do + File.write!(path, IO.ANSI.format(output, false)) + Mix.Shell.IO.info(["Written token information to ", :green, path]) + else + Mix.Shell.IO.info(["\n" | output]) + end + end + + def default_ttl do + @default_ttl_seconds + end + + @spec format_tokens([map()], atom) :: IO.ANSI.ansilist() + defp format_tokens(tokens, :cli) do + for %{user_id: user_id, token: token, expiry: expiry} <- tokens do + [ + "user id: ", + :bright, + user_id, + :reset, + "\n token: ", + :green, + token, + :reset, + "\nexpiry: ", + to_string(expiry), + "\n\n" + ] + end + end + + defp format_tokens(tokens, :csv) do + sep = "," + + for %{user_id: user_id, token: token, expiry: expiry} <- tokens do + [user_id, sep, token, sep, DateTime.to_iso8601(expiry), "\n"] + end + end + + defp format_tokens(tokens, :json) do + Application.ensure_all_started(:jason) + + Jason.encode!( + Map.new(tokens, fn %{user_id: user_id, token: token, expiry: expiry} -> + {user_id, %{token: token, expiry: expiry}} + end), + pretty: true + ) + end +end diff --git a/test/electric/satellite/satellite_ws_test.exs b/test/electric/satellite/satellite_ws_test.exs index 53d75e49..794613cd 100644 --- a/test/electric/satellite/satellite_ws_test.exs +++ b/test/electric/satellite/satellite_ws_test.exs @@ -57,12 +57,10 @@ defmodule Electric.Satellite.WsServerTest do columns ) - global_cluster_id = Electric.global_cluster_id() port = 55133 auth_provider = {Electric.Satellite.Auth.JWT, - global_cluster_id: global_cluster_id, issuer: "electric-sql.com", secret_key: Base.decode64!("BdvUDsCk5QbwkxI0fpEFmM/LNtFvwPZeMfHxvcOoS7s=")} @@ -73,12 +71,14 @@ defmodule Electric.Satellite.WsServerTest do auth_provider: auth_provider ) + server_id = Electric.regional_id() + on_exit(fn -> SchemaRegistry.clear_replicated_tables(@test_publication) :cowboy.stop_listener(:ws_test) end) - {:ok, auth_provider: auth_provider, port: port, global_cluster_id: global_cluster_id} + {:ok, auth_provider: auth_provider, port: port, server_id: server_id} end setup_with_mocks([ @@ -134,7 +134,7 @@ defmodule Electric.Satellite.WsServerTest do [port: cxt.port], fn conn -> MockClient.send_data(conn, %SatAuthReq{id: cxt.client_id, token: cxt.token}) - server_id = cxt.global_cluster_id + server_id = cxt.server_id assert_receive {^conn, %SatAuthResp{id: ^server_id}}, @default_wait end ) @@ -162,7 +162,7 @@ defmodule Electric.Satellite.WsServerTest do with_connect([port: cxt.port], fn conn -> MockClient.send_data(conn, %SatAuthReq{id: cxt.client_id, token: cxt.token}) - server_id = cxt.global_cluster_id + server_id = cxt.server_id assert_receive {_, %SatAuthResp{id: ^server_id}}, @default_wait MockClient.send_data(conn, %SatPingReq{}) @@ -171,7 +171,7 @@ defmodule Electric.Satellite.WsServerTest do end test "Auth is handled", cxt do - server_id = cxt.global_cluster_id + server_id = cxt.server_id with_connect([port: cxt.port], fn conn -> MockClient.send_data(conn, %SatPingReq{}) @@ -209,7 +209,7 @@ defmodule Electric.Satellite.WsServerTest do key = Keyword.fetch!(config, :secret_key) assert {:ok, invalid_token} = - Electric.Satellite.Auth.JWT.Token.create("some-other-cluster-id", cxt.user_id, key) + Electric.Satellite.Auth.JWT.Token.create(cxt.user_id, key, "some-other-cluster-id") with_connect([port: cxt.port], fn conn -> MockClient.send_data(conn, %SatAuthReq{id: "client_id", token: invalid_token}) diff --git a/test/electric_test.exs b/test/electric_test.exs index 2f385012..20de793b 100644 --- a/test/electric_test.exs +++ b/test/electric_test.exs @@ -1,7 +1,11 @@ defmodule ElectricTest do use ExUnit.Case, async: true - test "global_cluster_id/0" do - assert Electric.global_cluster_id() == "electric-development-cluster-0000" + test "regional_id/0" do + assert Electric.regional_id() == "region-1.test.electric-db" + end + + test "instance_id/0" do + assert Electric.instance_id() == "instance-1.region-1.test.electric-db" end end diff --git a/test/mix/tasks/electric.gen.token_test.exs b/test/mix/tasks/electric.gen.token_test.exs new file mode 100644 index 00000000..9c7b7103 --- /dev/null +++ b/test/mix/tasks/electric.gen.token_test.exs @@ -0,0 +1,102 @@ +defmodule Mix.Tasks.Electric.Gen.TokenTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Electric.Gen.Token, as: Task + alias Electric.Satellite.{Auth, Auth.JWT} + + import ExUnit.CaptureIO + + defp validate_json_output(output, usernames, ttl) do + assert {:ok, tokens} = Jason.decode(output) + assert is_map(tokens) + assert Map.keys(tokens) == usernames + + for {user_id, token_info} <- tokens do + assert %{"token" => token, "expiry" => expiry} = token_info + assert {:ok, %Auth{user_id: ^user_id}} = JWT.validate_token(token) + assert {:ok, datetime, 0} = DateTime.from_iso8601(expiry) + assert_in_delta(DateTime.diff(datetime, DateTime.utc_now()), ttl, 1) + end + end + + defp validate_csv_output(output, usernames, ttl) do + lines = output |> String.trim() |> String.split() + + assert length(lines) == length(usernames) + + users = + for line <- lines do + assert [user_id, token, expiry] = String.split(line, ",") + assert {:ok, %Auth{user_id: ^user_id}} = JWT.validate_token(token) + assert {:ok, datetime, 0} = DateTime.from_iso8601(expiry) + assert_in_delta(DateTime.diff(datetime, DateTime.utc_now()), ttl, 1) + user_id + end + + assert users == usernames + end + + test "csv output" do + {:ok, output} = + with_io(fn -> + Task.run(~w(--format csv user1 user2)) + end) + + validate_csv_output(output, ["user1", "user2"], Task.default_ttl()) + end + + test "json output" do + {:ok, output} = + with_io(fn -> + Task.run(~w(--format json user1 user2)) + end) + + validate_json_output(output, ["user1", "user2"], Task.default_ttl()) + end + + test "csv output with custom ttl" do + ttl = 3600 + + {:ok, output} = + with_io(fn -> + Task.run(~w(--format csv --ttl #{ttl} user1 user2)) + end) + + validate_csv_output(output, ["user1", "user2"], ttl) + end + + test "json output with custom ttl" do + ttl = 3600 + + {:ok, output} = + with_io(fn -> + Task.run(~w(--format json --ttl #{ttl} user1 user2)) + end) + + validate_json_output(output, ["user1", "user2"], ttl) + end + + @tag :tmp_dir + test "csv output to file", cxt do + ttl = 3600 + path = Path.join(cxt.tmp_dir, "creds.csv") + + with_io(fn -> + Task.run(~w(--format csv --output #{path} --ttl #{ttl} user1 user2)) + end) + + validate_csv_output(File.read!(path), ["user1", "user2"], ttl) + end + + @tag :tmp_dir + test "json output to file", cxt do + ttl = 3600 + path = Path.join(cxt.tmp_dir, "creds.json") + + with_io(fn -> + Task.run(~w(--format json --output #{path} --ttl #{ttl} user1 user2)) + end) + + validate_json_output(File.read!(path), ["user1", "user2"], ttl) + end +end