diff --git a/config/dev.exs b/config/dev.exs index 03fb9b372..61dc57718 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -232,3 +232,12 @@ config :ret, Ret.Locking, config :ret, Ret.Repo.Migrations.AdminSchemaInit, postgrest_password: "password" config :ret, Ret.StatsJob, node_stats_enabled: false, node_gauges_enabled: false config :ret, Ret.Coturn, realm: "ret" + +# OIDC test server https://oidctest.wsweet.org/ +config :ret, Ret.RemoteOIDCClient, + openid_configuration: "https://oidctest.wsweet.org/.well-known/openid-configuration", + scopes: "openid profile email roles", + permitted_claims: ["sub", "email", "name", "preferred_username", "roles"], + client_id: "private", + client_secret: "tardis", + additional_authorization_parameters: "&prompt=select_account" diff --git a/config/prod.exs b/config/prod.exs index 2b7321166..353c83424 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -164,3 +164,11 @@ config :ret, Ret.StatsJob, node_stats_enabled: false, node_gauges_enabled: false # Default repo check and page check to off so for polycosm hosts database + s3 hits can go idle config :ret, RetWeb.HealthController, check_repo: false + +config :ret, Ret.RemoteOIDCClient, + # Conventional default scopes + scopes: "openid profile email", + # Standard claims https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + permitted_claims: ["sub", "name", "given_name", "family_name", "middle_name", "nickname", + "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", + "birthdate", "zoneinfo", "locale", "phone_number", "phone_number_verified", "address", "updated_at"] diff --git a/habitat/config/config.toml b/habitat/config/config.toml index 0b36a5f62..e525ca37b 100644 --- a/habitat/config/config.toml +++ b/habitat/config/config.toml @@ -301,6 +301,19 @@ realm = "{{ cfg.turn.realm }}" public_tls_ports = "{{ cfg.turn.public_tls_ports }}" {{/if}} +{{#if cfg.oidc }} +{{#with cfg.oidc }} +[ret."Elixir.Ret.RemoteOIDCClient"] +openid_configuration = {{ toToml openid_configuration }} +scopes = {{ toToml scopes }} +permitted_claims = {{ toToml permitted_claims }} +client_id = {{ toToml client_id }} +client_secret = {{ toToml client_secret }} +additional_authorization_parameters = {{ toToml additional_authorization_parameters }} + +{{/with}} +{{/if}} + [web_push_encryption.vapid_details] subject = "{{ cfg.web_push.subject }}" public_key = "{{ cfg.web_push.public_key }}" diff --git a/lib/ret/oauth_token.ex b/lib/ret/oauth_token.ex index a23f765be..af0557601 100644 --- a/lib/ret/oauth_token.ex +++ b/lib/ret/oauth_token.ex @@ -36,6 +36,20 @@ defmodule Ret.OAuthToken do token end + + def token_for_oidc_request(topic_key, session_id) do + {:ok, token, _claims} = + Ret.OAuthToken.encode_and_sign( + # OAuthTokens do not have a resource associated with them + nil, + %{topic_key: topic_key, session_id: session_id, aud: :ret_oidc}, + allowed_algos: ["HS512"], + ttl: {10, :minutes}, + allowed_drift: 60 * 1000 + ) + + token + end end defmodule Ret.OAuthTokenSecretFetcher do diff --git a/lib/ret/remote_oidc_client.ex b/lib/ret/remote_oidc_client.ex new file mode 100644 index 000000000..74cc4140d --- /dev/null +++ b/lib/ret/remote_oidc_client.ex @@ -0,0 +1,91 @@ +defmodule Ret.RemoteOIDCClient do + @moduledoc """ + This represents an OpenID client configured via the openid_configuration parameter, + which should point to a discovery endpoint https://openid.net/specs/openid-connect-discovery-1_0.html + Downloaded configuration files openid-configuration and jwks_uri are cached indefinately. + """ + + require Logger + + def get_openid_configuration_uri() do + Application.get_env(:ret, __MODULE__)[:openid_configuration] + end + + defp download_openid_configuration() do + Logger.info("Downloading OIDC configuration from #{get_openid_configuration_uri()}") + result = get_openid_configuration_uri() + |> Ret.HttpUtils.retry_get_until_success + |> Map.get(:body) + |> Poison.decode!() + :persistent_term.put(:openid_configuration_cache, result) + Logger.info("Downloaded OIDC configuration: #{inspect(result)}") + result + end + + defp get_openid_configuration() do + :persistent_term.get(:openid_configuration_cache, nil) || download_openid_configuration() + end + + defp get_jwks_uri() do + get_openid_configuration() |> Map.get("jwks_uri") + end + + defp download_jwks() do + Logger.info("Downloading JWKS from #{get_jwks_uri()}") + result = get_jwks_uri() + |> Ret.HttpUtils.retry_get_until_success + |> Map.get(:body) + |> Poison.decode!() + result |> IO.inspect + :persistent_term.put(:openid_jwks_cache, result) + Logger.info("Downloaded JWKS: #{inspect(result)}") + result + end + + def get_jwks() do + :persistent_term.get(:openid_jwks_cache, nil) || download_jwks() + end + + def get_auth_endpoint() do + get_openid_configuration() |> Map.get("authorization_endpoint") + end + + def get_token_endpoint() do + get_openid_configuration() |> Map.get("token_endpoint") + end + + def get_allowed_algos() do + get_openid_configuration() |> Map.get("id_token_signing_alg_values_supported") + end + + def get_userinfo_endpoint() do + # Optional in spec + get_openid_configuration() |> Map.get("userinfo_endpoint") + end + + def get_scopes_supported() do + # Optional in spec + get_openid_configuration() |> Map.get("scopes_supported") + end + + def get_scopes() do + Application.get_env(:ret, __MODULE__)[:scopes] + end + + def get_permitted_claims() do + Application.get_env(:ret, __MODULE__)[:permitted_claims] + end + + def get_client_id() do + Application.get_env(:ret, __MODULE__)[:client_id] + end + + def get_client_secret() do + Application.get_env(:ret, __MODULE__)[:client_secret] + end + + def get_additional_authorization_parameters() do + Application.get_env(:ret, __MODULE__)[:additional_authorization_parameters] + end + +end diff --git a/lib/ret/remote_oidc_token.ex b/lib/ret/remote_oidc_token.ex new file mode 100644 index 000000000..e6f74b39c --- /dev/null +++ b/lib/ret/remote_oidc_token.ex @@ -0,0 +1,38 @@ +defmodule Ret.RemoteOIDCToken do + @moduledoc """ + This represents an OpenID Connect token returned from a remote service. + These tokens are never created locally, only ever provided externally and verified locally. + """ + use Guardian, + otp_app: :ret, + secret_fetcher: Ret.RemoteOIDCTokenSecretsFetcher + + def subject_for_token(_, _), do: {:ok, nil} + def resource_from_claims(_), do: {:ok, nil} +end + +defmodule Ret.RemoteOIDCTokenSecretsFetcher do + @moduledoc """ + This represents the public keys for an OpenID Connect endpoint used to verify tokens. + The public keys will be configured by an admin for a particular setup. These can not be used for signing. + """ + + def fetch_signing_secret(_mod, _opts) do + {:error, :not_implemented} + end + + def fetch_verifying_secret(_mod, %{"kid" => kid, "typ" => "JWT"}, _opts) do + # TODO force cache to refresh when unknown kid found to support key rotation + # as per https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys + case Ret.RemoteOIDCClient.get_jwks() + |> Map.get("keys") + |> Enum.find(&(Map.get(&1, "kid") == kid)) do + nil -> {:error, :invalid_key_id} + key -> {:ok, key |> JOSE.JWK.from_map()} + end + end + + def fetch_verifying_secret(_mod, _token_headers_, _optss) do + {:error, :invalid_token} + end +end diff --git a/lib/ret_web/channels/auth_channel.ex b/lib/ret_web/channels/auth_channel.ex index de9e78f0d..68abf7f07 100644 --- a/lib/ret_web/channels/auth_channel.ex +++ b/lib/ret_web/channels/auth_channel.ex @@ -4,7 +4,7 @@ defmodule RetWeb.AuthChannel do use RetWeb, :channel import Canada, only: [can?: 2] - alias Ret.{Statix, LoginToken, Account, Crypto} + alias Ret.{Statix, LoginToken, Account, Crypto, AppConfig} intercept(["auth_credentials"]) @@ -25,8 +25,10 @@ defmodule RetWeb.AuthChannel do account = email |> Account.account_for_email() account_disabled = account && account.state == :disabled + # Accounts can only be created if the general setting is enabled and the server is not in OIDC mode + can_create_email_accounts = can?(nil, create_account(nil)) && !AppConfig.get_config_bool("auth|use_oidc") - if !account_disabled && (can?(nil, create_account(nil)) || !!account) do + if !account_disabled && (can_create_email_accounts || !!account) do # Create token + send email %LoginToken{token: token, payload_key: payload_key} = LoginToken.new_login_token_for_email(email) @@ -98,7 +100,9 @@ defmodule RetWeb.AuthChannel do defp broadcast_credentials_and_payload(nil, _payload, _socket), do: nil defp broadcast_credentials_and_payload(identifier_hash, payload, socket) do - account = identifier_hash |> Account.account_for_login_identifier_hash(can?(nil, create_account(nil))) + # Accounts can only be created if the general setting is enabled and the server is not in OIDC mode + can_create_email_accounts = can?(nil, create_account(nil)) && !AppConfig.get_config_bool("auth|use_oidc") + account = identifier_hash |> Account.account_for_login_identifier_hash(can_create_email_accounts) credentials = account |> Account.credentials_for_account() broadcast!(socket, "auth_credentials", %{credentials: credentials, payload: payload}) end diff --git a/lib/ret_web/channels/oidc_auth_channel.ex b/lib/ret_web/channels/oidc_auth_channel.ex new file mode 100644 index 000000000..3897a015a --- /dev/null +++ b/lib/ret_web/channels/oidc_auth_channel.ex @@ -0,0 +1,190 @@ +defmodule RetWeb.OIDCAuthChannel do + @moduledoc "Ret Web Channel for OpenID Connect Authentication" + + require Logger + + use RetWeb, :channel + import Canada, only: [can?: 2] + + alias Ret.{Account, OAuthToken, RemoteOIDCClient, RemoteOIDCToken, AppConfig} + + intercept(["auth_credentials"]) + + # Intersection of possible values for JSON signing https://www.rfc-editor.org/rfc/rfc7518#section-3.1 + # and algorithms supported by JOSE https://hexdocs.pm/jose/JOSE.JWS.html#module-algorithms + @supported_algorithms ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"] + + def join("oidc:" <> _topic_key, _payload, socket) do + # Expire channel in 5 minutes + Process.send_after(self(), :channel_expired, 60 * 1000 * 5) + + # Rate limit joins to reduce attack surface + :timer.sleep(500) + + {:ok, %{session_id: socket.assigns.session_id}, socket} + end + + defp get_redirect_uri(), do: RetWeb.Endpoint.url() <> "/verify" + + defp get_authorize_url(state, nonce) do + RemoteOIDCClient.get_auth_endpoint() <> "?" <> + URI.encode_query(%{ + response_type: "code", + response_mode: "query", + client_id: RemoteOIDCClient.get_client_id(), + scope: RemoteOIDCClient.get_scopes(), + state: state, + nonce: nonce, + redirect_uri: get_redirect_uri() + }) <> RemoteOIDCClient.get_additional_authorization_parameters() + end + + defp fetch_user_info(access_token) do + # user info endpoint is optional + case RemoteOIDCClient.get_userinfo_endpoint() do + nil -> nil + url -> url + |> Ret.HttpUtils.retry_get_until_success(headers: [{"authorization", "Bearer #{access_token}"}]) + |> Map.get(:body) + |> Poison.decode!() + end + end + + def handle_in("auth_request", _payload, socket) do + if Map.get(socket.assigns, :nonce) do + {:reply, {:error, "Already started an auth request on this session"}, socket} + else + if AppConfig.get_config_bool("auth|use_oidc") do + "oidc:" <> topic_key = socket.topic + oidc_state = Ret.OAuthToken.token_for_oidc_request(topic_key, socket.assigns.session_id) + nonce = SecureRandom.uuid() + authorize_url = get_authorize_url(oidc_state, nonce) + + socket = socket |> assign(:nonce, nonce) + + {:reply, {:ok, %{authorize_url: authorize_url}}, socket} + else + {:reply, {:error, %{message: "OpenID Connect not enabled"}}, socket} + end + end + end + + def handle_in("auth_verified", %{"token" => code, "payload" => state}, socket) do + Process.send_after(self(), :close_channel, 1000 * 5) + + # Slow down any brute force attacks + :timer.sleep(500) + + "oidc:" <> expected_topic_key = socket.topic + + with {:ok, + %{ + "topic_key" => topic_key, + "session_id" => session_id, + "aud" => "ret_oidc" + }} + when topic_key == expected_topic_key <- OAuthToken.decode_and_verify(state), + {:ok, + %{ + "access_token" => access_token, + "id_token" => raw_id_token + }} <- fetch_oidc_tokens(code), + {:ok, + %{ + "aud" => _aud, + "nonce" => nonce, + "sub" => remote_user_id + } = id_token} <- RemoteOIDCToken.decode_and_verify(raw_id_token, %{}, allowed_algos: @supported_algorithms) do + + # Searchable identifier is unique to the OIDC provider and user + identifier_hash = RemoteOIDCClient.get_openid_configuration_uri() <> "#" <> remote_user_id + |> Account.identifier_hash_for_email() + + # The OIDC user info endpoint is optional, so if it missing we assume info will be in the id token instead + # and filter for just the permitted claims + all_claims = fetch_user_info(access_token) || id_token + permitted_claims = RemoteOIDCClient.get_permitted_claims() + filtered_claims = :maps.filter(fn key, _val -> key in permitted_claims end, all_claims) + + broadcast_credentials_and_payload( + identifier_hash, + %{oidc: filtered_claims}, + %{session_id: session_id, nonce: nonce}, + socket + ) + + {:reply, :ok, socket} + else + {:error, error} -> + # Error messages from Guardian are very limited https://github.com/ueberauth/guardian/issues/711 + Logger.warn("OIDC error: #{inspect(error)}") + {:reply, {:error, %{message: "error fetching or verifying token"}}, socket} + end + end + + def handle_in(_event, _payload, socket) do + {:noreply, socket} + end + + defp fetch_oidc_tokens(oauth_code) do + body = + {:form, + [ + client_id: RemoteOIDCClient.get_client_id(), + client_secret: RemoteOIDCClient.get_client_secret(), + grant_type: "authorization_code", + redirect_uri: get_redirect_uri(), + code: oauth_code, + scope: RemoteOIDCClient.get_scopes() + ]} + + options = [ + headers: [{"content-type", "application/x-www-form-urlencoded"}] + ] + + case Ret.HttpUtils.retry_post_until_success(RemoteOIDCClient.get_token_endpoint(), body, options) do + %HTTPoison.Response{body: body} -> body |> Poison.decode() + _ -> {:error, "Failed to fetch tokens"} + end + end + + def handle_info(:close_channel, socket) do + GenServer.cast(self(), :close) + {:noreply, socket} + end + + def handle_info(:channel_expired, socket) do + GenServer.cast(self(), :close) + {:noreply, socket} + end + + # Only send credentials back down to the original socket that started the request + def handle_out( + "auth_credentials" = event, + %{credentials: credentials, user_info: user_info, verification_info: verification_info}, + socket + ) do + Process.send_after(self(), :close_channel, 1000 * 5) + + if Map.get(socket.assigns, :session_id) == Map.get(verification_info, :session_id) and + Map.get(socket.assigns, :nonce) == Map.get(verification_info, :nonce) do + push(socket, event, %{credentials: credentials, user_info: user_info}) + end + + {:noreply, socket} + end + + defp broadcast_credentials_and_payload(nil, _user_info, _verification_info, _socket), do: nil + + defp broadcast_credentials_and_payload(identifier_hash, user_info, verification_info, socket) do + account_creation_enabled = can?(nil, create_account(nil)) + account = identifier_hash |> Account.account_for_login_identifier_hash(account_creation_enabled) + credentials = account |> Account.credentials_for_account() + + broadcast!(socket, "auth_credentials", %{ + credentials: credentials, + user_info: user_info, + verification_info: verification_info + }) + end +end diff --git a/lib/ret_web/channels/session_socket.ex b/lib/ret_web/channels/session_socket.ex index f2efb92d4..a99bdc8c8 100644 --- a/lib/ret_web/channels/session_socket.ex +++ b/lib/ret_web/channels/session_socket.ex @@ -5,6 +5,7 @@ defmodule RetWeb.SessionSocket do channel("hub:*", RetWeb.HubChannel) channel("link:*", RetWeb.LinkChannel) channel("auth:*", RetWeb.AuthChannel) + channel("oidc:*", RetWeb.OIDCAuthChannel) def id(socket) do "session:#{socket.assigns.session_id}"