From 055232ce46f39b73b455cc0f02273ca8387e1e0c Mon Sep 17 00:00:00 2001
From: Mark Percival
+ <%= link( + "Sign in with #{Keyword.get(config, :label)}", + to: @openid_connect.authorization_uri(provider), + class: "button") %> +
+ <% end %> + <% end %> + <%= if @local_enabled do %><%= link( diff --git a/apps/fz_http/lib/fz_http_web/user_from_auth.ex b/apps/fz_http/lib/fz_http_web/user_from_auth.ex index 7a804a4f3..edd8b934e 100644 --- a/apps/fz_http/lib/fz_http_web/user_from_auth.ex +++ b/apps/fz_http/lib/fz_http_web/user_from_auth.ex @@ -24,4 +24,11 @@ defmodule FzHttpWeb.UserFromAuth do user -> {:ok, user} end end + + def find_or_create(_provider, %{"email" => email, "sub" => _sub, "email_verified" => true}) do + case Users.get_by_email(email) do + nil -> Users.create_unprivileged_user(%{email: email}) + user -> {:ok, user} + end + end end diff --git a/apps/fz_http/mix.exs b/apps/fz_http/mix.exs index 8097dfc43..dd69b8103 100644 --- a/apps/fz_http/mix.exs +++ b/apps/fz_http/mix.exs @@ -64,8 +64,10 @@ defmodule FzHttp.MixProject do {:cloak_ecto, "~> 1.2"}, {:excoveralls, "~> 0.14", only: :test}, {:floki, ">= 0.0.0", only: :test}, + {:mox, "~> 1.0.1", only: :test}, {:guardian, "~> 2.0"}, {:guardian_db, "~> 2.0"}, + {:openid_connect, "~> 0.2.2"}, {:ueberauth, "~> 0.7"}, {:ueberauth_google, "~> 0.10"}, {:ueberauth_okta, "~> 0.2"}, diff --git a/apps/fz_http/test/fz_http_web/controllers/session_controller_test.exs b/apps/fz_http/test/fz_http_web/controllers/session_controller_test.exs index 3a8042d60..edb50fd24 100644 --- a/apps/fz_http/test/fz_http_web/controllers/session_controller_test.exs +++ b/apps/fz_http/test/fz_http_web/controllers/session_controller_test.exs @@ -1,13 +1,24 @@ defmodule FzHttpWeb.AuthControllerTest do use FzHttpWeb.ConnCase, async: true + import Mox + describe "new" do setup [:create_user] test "unauthed: loads the sign in form", %{unauthed_conn: conn} do + expect(OpenIDConnect.Mock, :authorization_uri, fn _ -> "https://auth.url" end) test_conn = get(conn, Routes.root_path(conn, :index)) - assert html_response(test_conn, 200) =~ "Sign In" + # Assert that we email, OIDC and Oauth2 buttons provided + for expected <- [ + "Sign in with email", + "Sign in with OIDC Google", + "Sign in with Google", + "Sign in with Okta" + ] do + assert html_response(test_conn, 200) =~ expected + end end test "authed as admin: redirects to users page", %{admin_conn: conn} do @@ -63,6 +74,44 @@ defmodule FzHttpWeb.AuthControllerTest do end end + describe "creating session from OpenID Connect" do + setup [:create_user] + + test "when a user returns with a valid claim", %{unauthed_conn: conn, user: user} do + expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ -> {:ok, %{"id_token" => "abc"}} end) + + expect(OpenIDConnect.Mock, :verify, fn _, _ -> + {:ok, %{"email" => user.email, "email_verified" => true, "sub" => "12345"}} + end) + + params = %{ + "code" => "MyFaketoken", + "provider" => "google" + } + + test_conn = get(conn, Routes.auth_oidc_path(conn, :callback, "google"), params) + + assert redirected_to(test_conn) == Routes.user_index_path(test_conn, :index) + end + + @moduletag :capture_log + test "when a user returns with an invalid claim", %{unauthed_conn: conn} do + expect(OpenIDConnect.Mock, :fetch_tokens, fn _, _ -> {:ok, %{}} end) + + expect(OpenIDConnect.Mock, :verify, fn _, _ -> + {:error, "Invalid token for user!"} + end) + + params = %{ + "code" => "MyFaketoken", + "provider" => "google" + } + + test_conn = get(conn, Routes.auth_oidc_path(conn, :callback, "google"), params) + assert get_flash(test_conn, :error) == "OpenIDConnect Error: Invalid token for user!" + end + end + describe "when deleting a session" do setup :create_user diff --git a/apps/fz_http/test/support/mock_openid_connect.ex b/apps/fz_http/test/support/mock_openid_connect.ex new file mode 100644 index 000000000..8b259785b --- /dev/null +++ b/apps/fz_http/test/support/mock_openid_connect.ex @@ -0,0 +1,8 @@ +defmodule OpenIDConnect.MockBehaviour do + @moduledoc """ + Mock Behaviour for OpenIDConnect so that we can use Mox + """ + @callback authorization_uri(any) :: String.t() + @callback fetch_tokens(any, map) :: {:ok, any} | {:error, any} + @callback verify(any, map) :: {:ok, any} | {:error, any} +end diff --git a/apps/fz_http/test/test_helper.exs b/apps/fz_http/test/test_helper.exs index 1a2aee940..586a358b0 100644 --- a/apps/fz_http/test/test_helper.exs +++ b/apps/fz_http/test/test_helper.exs @@ -1,2 +1,4 @@ +Mox.defmock(OpenIDConnect.Mock, for: OpenIDConnect.MockBehaviour) + ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(FzHttp.Repo, :manual) diff --git a/config/config.exs b/config/config.exs index 9b093c94f..9e2b00f5c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -75,7 +75,9 @@ config :fz_http, admin_email: "firezone@localhost", default_admin_password: "firezone1234", events_module: FzHttp.Events, - server_process_opts: [name: {:global, :fz_http_server}] + server_process_opts: [name: {:global, :fz_http_server}], + openid_connect_providers: [], + openid_connect: OpenIDConnect config :fz_wall, cli: FzWall.CLI.Sandbox, diff --git a/config/runtime.exs b/config/runtime.exs index 35c0f7551..90f10ec97 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -4,6 +4,7 @@ # remember to add this file to your .gitignore. import Config + alias FzCommon.{CLI, FzInteger, FzString} # Optional config across all envs @@ -250,3 +251,29 @@ if config_env() == :prod do redirect_uri: google_redirect_uri end end + +# OIDC Auth +auth_oidc_env = System.get_env("AUTH_OIDC") + +if auth_oidc_env do + auth_oidc = + Jason.decode!(auth_oidc_env) + # Convert Map to something openid_connect expects, atomic keyed configs + # eg. [provider: [client_id: "CLIENT_ID" ...]] + |> Enum.map(fn {provider, settings} -> + { + String.to_atom(provider), + [ + discovery_document_uri: settings["discovery_document_uri"], + client_id: settings["client_id"], + client_secret: settings["client_secret"], + redirect_uri: "#{external_url}/auth/oidc/#{provider}/callback/", + response_type: settings["response_type"], + scope: settings["scope"], + label: settings["label"] + ] + } + end) + + config :fz_http, :openid_connect_providers, auth_oidc +end diff --git a/config/test.exs b/config/test.exs index 68a6d625a..295b3be54 100644 --- a/config/test.exs +++ b/config/test.exs @@ -58,4 +58,20 @@ config :ueberauth, Ueberauth, {:google, {Ueberauth.Strategy.Google, []}} ] +# OIDC auth for testing +config :fz_http, :openid_connect_providers, %{ + google: [ + discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", + client_id: "CLIENT_ID", + client_secret: "CLIENT_SECRET", + redirect_uri: "https://firezone.example.com/auth/oidc/google/callback", + response_type: "code", + scope: "openid email profile", + label: "OIDC Google" + ] +} + +# Provide mock for HTTPClient +config :fz_http, :openid_connect, OpenIDConnect.Mock + config :fz_http, FzHttp.Mailer, adapter: Swoosh.Adapters.Test, from_email: "test@firez.one" diff --git a/docs/docs/reference/configuration-file.md b/docs/docs/reference/configuration-file.md index 7d3d233e4..e32ab73cd 100644 --- a/docs/docs/reference/configuration-file.md +++ b/docs/docs/reference/configuration-file.md @@ -35,6 +35,7 @@ Shown below is a complete listing of the configuration options available in | `default['firezone']['install_path']` | Install path used by Chef 'enterprise' cookbook. Should be set to the same as the `install_directory` above. | `node['firezone']['install_directory']` | | `default['firezone']['sysvinit_id']` | An identifier used in `/etc/inittab`. Must be a unique sequence of 1-4 characters. | `'SUP'` | | `default['firezone']['authentication']['local']['enabled'] = true` | Enable or disable local email/password authentication. | `true` | +| `default['firezone']['authentication']['oidc']` | OpenID Connect config, in the format of `{"provider" => [config...]}` - See [OpenIDConnect documentation](https://hexdocs.pm/openid_connect/readme.html) for config examples. | `{}` | | `default['firezone']['authentication']['okta']['enabled'] = false` | Enable or disable Okta SSO authentication. | `false` | | `default['firezone']['authentication']['okta']['client_id'] = nil` | OAuth Client ID for Okta SSO authentication. | `nil` | | `default['firezone']['authentication']['okta']['client_secret'] = nil` | OAuth Client Secret for Okta SSO authentication. | `nil` | diff --git a/mix.lock b/mix.lock index 8d88187b0..4897fe562 100644 --- a/mix.lock +++ b/mix.lock @@ -45,8 +45,10 @@ "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, + "mox": {:hex, :mox, "1.0.1", "b651bf0113265cda0ba3a827fcb691f848b683c373b77e7d7439910a8d754d6e", [:mix], [], "hexpm", "35bc0dea5499d18db4ef7fe4360067a59b06c74376eb6ab3bd67e6295b133469"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, + "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, diff --git a/omnibus/config/projects/firezone.rb b/omnibus/config/projects/firezone.rb index edfe08ad8..ac9dda866 100644 --- a/omnibus/config/projects/firezone.rb +++ b/omnibus/config/projects/firezone.rb @@ -51,6 +51,7 @@ dependency 'firezone-cookbooks' # XXX: Ensure all development resources aren't included exclude '.env' +exclude '.devcontainer' exclude '.github' exclude '.vagrant' exclude '.ci' diff --git a/omnibus/cookbooks/firezone/attributes/default.rb b/omnibus/cookbooks/firezone/attributes/default.rb index b1e460c6f..428432b8c 100644 --- a/omnibus/cookbooks/firezone/attributes/default.rb +++ b/omnibus/cookbooks/firezone/attributes/default.rb @@ -99,13 +99,31 @@ default['firezone']['sysvinit_id'] = 'SUP' # Local email/password authentication is enabled by default default['firezone']['authentication']['local']['enabled'] = true -# If using the 'okta' authentication method, set 'enabled' to true and configure relevant settings below. +# OIDC Authentication +# Any OpenID Connect provider can be used here. +# Example of a Google setup +default['firezone']['authentication']['oidc']['google'] = { + discovery_document_uri: "https://accounts.google.com/.well-known/openid-configuration", + client_id: "CLIENT_ID", + client_secret: "CLIENT_SECRET", + redirect_uri: "https://firezone.example.com/auth/oidc/google/callback", + response_type: "code", + scope: "openid email profile", + label: "Google" +} + +# DEPRECATED +# Previously, Firezone used preconfigured Oauth2 providers. We've moved to OIDC authentication +# which allows for any OpenID Connect provider (Google, Okta, Dex) to be used for authetication. +# See the above OIDC Authentication section +# +# DEPRECATED: Okta example config default['firezone']['authentication']['okta']['enabled'] = false default['firezone']['authentication']['okta']['client_id'] = nil default['firezone']['authentication']['okta']['client_secret'] = nil default['firezone']['authentication']['okta']['site'] = 'https://your-domain.okta.com' -# If using the 'google' authentication method, set 'enabled' to true and configure relevant settings below. +# DEPRECATED: Google example config default['firezone']['authentication']['google']['enabled'] = false default['firezone']['authentication']['google']['client_id'] = nil default['firezone']['authentication']['google']['client_secret'] = nil diff --git a/omnibus/cookbooks/firezone/libraries/config.rb b/omnibus/cookbooks/firezone/libraries/config.rb index c8652b904..87ab4df36 100644 --- a/omnibus/cookbooks/firezone/libraries/config.rb +++ b/omnibus/cookbooks/firezone/libraries/config.rb @@ -267,6 +267,9 @@ class Firezone 'GOOGLE_CLIENT_SECRET' => attributes['authentication']['google']['client_secret'], 'GOOGLE_REDIRECT_URI' => attributes['authentication']['google']['redirect_uri'], + # OpenID Connect auth settings are serialized to json for consumption by fz_http + 'AUTH_OIDC' => attributes['authentication']['oidc'].to_json, + # secrets 'GUARDIAN_SECRET_KEY' => attributes['guardian_secret_key'], 'SECRET_KEY_BASE' => attributes['secret_key_base'],