Implement OpenID Connect for authentication (#586)

Implements the OpenID Connect standard for SSO Authentication
which allows users to use any OpenID Connect provider for authentication
not just a predefined list of providers

User can add OIDC config settings to firezone.rb which will then
populate the AUTH_OIDC environment variable as a JSON string.

FZ_HTTP will use this environment variable to create
provider(s) to authenticate against

Additional notes:
- Updates .env.sample to include an example of a 'stringified' JSON
environment variable for setting AUTH_OIDC in development
- Add dep for 'openid_connect' and test dep for 'mox'
This commit is contained in:
Mark Percival
2022-05-12 13:37:08 -04:00
committed by GitHub
parent 90c8ece94f
commit 055232ce46
21 changed files with 223 additions and 5 deletions

View File

@@ -10,3 +10,6 @@ LOCAL_AUTH_ENABLED=true
# Set PROXY_FORWARDED to true if you're running this behind a proxy or using
# GitHub codespaces
PROXY_FORWARDED=true
# Generated with `jq @json < .oidc_env.json`
# export AUTH_OIDC="{\"google\":{\"discovery_document_uri\":\"https://accounts.google.com/.well-known/openid-configuration\",\"client_id\":\"1032390727302-u0lg90d3i1ive15lv7qgtbkka0hnsmgr.apps.googleusercontent.com\",\"client_secret\":\"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\",\"redirect_uri\":\"https://example.com/session\",\"response_type\":\"code\",\"scope\":\"openid email profile\",\"label\":\"Google\"},\"okta\":{\"discovery_document_uri\":\"https://accounts.google.com/.well-known/openid-configuration\",\"client_id\":\"CLIENT_ID\",\"client_secret\":\"CLIENT_SECRET\",\"redirect_uri\":\"https://example.com/session\",\"response_type\":\"code\",\"scope\":\"openid email profile\",\"label\":\"Okta\"}}"

1
.gitignore vendored
View File

@@ -52,6 +52,7 @@ npm-debug.log
# Development environment configuration
.env
.oidc_env.json
# Built packages
/*.deb

11
.oidc_env.sample.json Normal file
View File

@@ -0,0 +1,11 @@
{
"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"
}
}

View File

@@ -25,6 +25,9 @@ defmodule FzHttp.Application do
defp children, do: children(Application.fetch_env!(:fz_http, :supervision_tree_mode))
defp children(:full) do
# Pull in OpenIDConnect config if available
openid_connect_providers = Application.get_env(:fz_http, :openid_connect_providers)
[
FzHttp.Server,
FzHttp.Repo,
@@ -35,6 +38,7 @@ defmodule FzHttp.Application do
FzHttp.ConnectivityCheckService,
FzHttp.VpnSessionScheduler
]
|> append_if(openid_connect_providers, {OpenIDConnect.Worker, openid_connect_providers})
end
defp children(:test) do
@@ -47,4 +51,8 @@ defmodule FzHttp.Application do
FzHttpWeb.Presence
]
end
defp append_if(list, condition, item) do
if condition, do: list ++ [item], else: list
end
end

View File

@@ -3,6 +3,7 @@ defmodule FzHttpWeb.AuthController do
Implements the CRUD for a Session
"""
use FzHttpWeb, :controller
require Logger
alias FzHttpWeb.Authentication
alias FzHttpWeb.Router.Helpers, as: Routes
@@ -48,6 +49,48 @@ defmodule FzHttpWeb.AuthController do
end
end
def callback(conn, params) do
%{"provider" => provider_key} = params
openid_connect = Application.fetch_env!(:fz_http, :openid_connect)
atomize = fn key ->
try do
{:ok, String.to_existing_atom(key)}
catch
ArgumentError -> {:error, "OIDC Provider not found"}
end
end
with {:ok, provider} <- atomize.(provider_key),
{:ok, tokens} <- openid_connect.fetch_tokens(provider, params),
{:ok, claims} <- openid_connect.verify(provider, tokens["id_token"]) do
case UserFromAuth.find_or_create(provider, claims) do
{:ok, user} ->
conn
|> configure_session(renew: true)
|> put_session(:live_socket_id, "users_socket:#{user.id}")
|> Authentication.sign_in(user, %{provider: provider})
|> redirect(to: root_path_for_role(conn, user.role))
{:error, reason} ->
conn
|> put_flash(:error, "Error signing in: #{reason}")
|> request(%{})
end
else
{:error, reason} ->
msg = "OpenIDConnect Error: #{reason}"
Logger.warn(msg)
conn
|> put_flash(:error, msg)
|> request(%{})
_ ->
send_resp(conn, 401, "")
end
end
def delete(conn, _params) do
conn
|> Authentication.sign_out()

View File

@@ -10,7 +10,9 @@ defmodule FzHttpWeb.RootController do
"auth.html",
okta_enabled: conf(:okta_auth_enabled),
google_enabled: conf(:google_auth_enabled),
local_enabled: conf(:local_auth_enabled)
local_enabled: conf(:local_auth_enabled),
openid_connect_providers: conf(:openid_connect_providers),
openid_connect: conf(:openid_connect)
)
end

View File

@@ -56,6 +56,7 @@ defmodule FzHttpWeb.Router do
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
post "/:provider/callback", AuthController, :callback
get "/oidc/:provider/callback", AuthController, :callback, as: :auth_oidc
end
# Unauthenticated routes

View File

@@ -7,6 +7,17 @@
Please sign in via one of the methods below.
</p>
<%= if @openid_connect_providers > 0 do %>
<%= for {provider, config} <- @openid_connect_providers do %>
<p>
<%= link(
"Sign in with #{Keyword.get(config, :label)}",
to: @openid_connect.authorization_uri(provider),
class: "button") %>
</p>
<% end %>
<% end %>
<%= if @local_enabled do %>
<p>
<%= link(

View File

@@ -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

View File

@@ -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"},

View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +1,4 @@
Mox.defmock(OpenIDConnect.Mock, for: OpenIDConnect.MockBehaviour)
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(FzHttp.Repo, :manual)

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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` |

View File

@@ -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"},

View File

@@ -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'

View File

@@ -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

View File

@@ -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'],