From 8a494b663b2f0c35496546d89370a1003795ec2c Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Thu, 14 May 2026 08:18:25 +0000 Subject: [PATCH 01/17] feat: support github sso --- lib/lightning/auth_providers/cache_warmer.ex | 28 ++++++++++--- .../auth_providers/github_handler.ex | 42 +++++++++++++++++++ lib/lightning/auth_providers/handler.ex | 20 ++++++--- lib/lightning/config.ex | 11 +++++ lib/lightning/config/bootstrap.ex | 17 ++++++++ .../controllers/oidc_controller.ex | 40 ++++++++++++++++-- .../user_registration_controller.ex | 10 ++++- .../user_registration_html/new.html.heex | 31 ++++++++++++++ .../controllers/user_session_controller.ex | 21 ++++++---- .../user_session_html/new.html.heex | 41 +++++++++++------- .../controllers/oidc_controller_test.exs | 21 ++++++---- .../user_session_controller_test.exs | 4 +- 12 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 lib/lightning/auth_providers/github_handler.ex diff --git a/lib/lightning/auth_providers/cache_warmer.ex b/lib/lightning/auth_providers/cache_warmer.ex index 21881a7796f..e55327adb83 100644 --- a/lib/lightning/auth_providers/cache_warmer.ex +++ b/lib/lightning/auth_providers/cache_warmer.ex @@ -20,12 +20,28 @@ defmodule Lightning.AuthProviders.CacheWarmer do Executes this cache warmer with a connection. """ def execute(_state) do - with %AuthProviders.AuthConfig{name: name} = config <- - AuthProviders.get_existing() || :ignore, - {:ok, handler} <- AuthProviders.Handler.from_model(config) do - {:ok, [{name, handler}]} - else - _error -> :ignore + db_entries = + try do + with %AuthProviders.AuthConfig{name: name} = config <- + AuthProviders.get_existing() || :not_found, + {:ok, handler} <- AuthProviders.Handler.from_model(config) do + [{name, handler}] + else + _ -> [] + end + rescue + _ -> [] + end + + github_entries = + case Lightning.AuthProviders.GithubHandler.build() do + {:ok, handler} -> [{handler.name, handler}] + _ -> [] + end + + case db_entries ++ github_entries do + [] -> :ignore + entries -> {:ok, entries} end end end diff --git a/lib/lightning/auth_providers/github_handler.ex b/lib/lightning/auth_providers/github_handler.ex new file mode 100644 index 00000000000..6b891152e40 --- /dev/null +++ b/lib/lightning/auth_providers/github_handler.ex @@ -0,0 +1,42 @@ +defmodule Lightning.AuthProviders.GithubHandler do + @moduledoc """ + Builds a Handler for GitHub OAuth2 SSO login from environment configuration. + + Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to enable GitHub login. + """ + + alias Lightning.AuthProviders.Handler + alias Lightning.AuthProviders.WellKnown + + @name "github" + @authorization_endpoint "https://github.com/login/oauth/authorize" + @token_endpoint "https://github.com/login/oauth/access_token" + @userinfo_endpoint "https://api.github.com/user/emails" + + def handler_name, do: @name + + @spec build() :: {:ok, Handler.t()} | {:error, :not_configured} + def build do + client_id = Lightning.Config.github_oauth(:client_id) + client_secret = Lightning.Config.github_oauth(:client_secret) + redirect_uri = Lightning.Config.github_oauth(:redirect_uri) + + if client_id && client_secret && redirect_uri do + wellknown = %WellKnown{ + authorization_endpoint: @authorization_endpoint, + token_endpoint: @token_endpoint, + userinfo_endpoint: @userinfo_endpoint + } + + Handler.new(@name, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + wellknown: wellknown, + scope: "user:email" + ) + else + {:error, :not_configured} + end + end +end diff --git a/lib/lightning/auth_providers/handler.ex b/lib/lightning/auth_providers/handler.ex index 9e814bec24f..c3757180518 100644 --- a/lib/lightning/auth_providers/handler.ex +++ b/lib/lightning/auth_providers/handler.ex @@ -10,17 +10,19 @@ defmodule Lightning.AuthProviders.Handler do @type t :: %__MODULE__{ name: String.t(), client: OAuth2.Client.t(), - wellknown: WellKnown.t() + wellknown: WellKnown.t(), + scope: String.t() } @type opts :: [ client_id: String.t(), client_secret: String.t(), redirect_uri: String.t(), - wellknown: WellKnown.t() + wellknown: WellKnown.t(), + scope: String.t() ] - defstruct [:name, :client, :wellknown] + defstruct [:name, :client, :wellknown, scope: "openid email profile"] @doc """ Create a new Provider struct, expects a name and opts: @@ -41,6 +43,7 @@ defmodule Lightning.AuthProviders.Handler do :ok -> wellknown = opts[:wellknown] + scope = opts[:scope] || "openid email profile" client = OAuth2.Client.new( @@ -54,7 +57,12 @@ defmodule Lightning.AuthProviders.Handler do |> OAuth2.Client.put_serializer("application/json", Jason) {:ok, - struct!(__MODULE__, name: name, client: client, wellknown: wellknown)} + struct!(__MODULE__, + name: name, + client: client, + wellknown: wellknown, + scope: scope + )} end end @@ -80,7 +88,7 @@ defmodule Lightning.AuthProviders.Handler do @spec authorize_url(handler :: __MODULE__.t()) :: String.t() def authorize_url(handler) do - OAuth2.Client.authorize_url!(handler.client, scope: "openid email profile") + OAuth2.Client.authorize_url!(handler.client, scope: handler.scope) end @spec get_token(handler :: __MODULE__.t(), code :: String.t()) :: @@ -88,7 +96,7 @@ defmodule Lightning.AuthProviders.Handler do def get_token(handler, code) when is_binary(code) do case OAuth2.Client.get_token(handler.client, code: code, - scope: "openid email profile" + scope: handler.scope ) do {:ok, client} -> {:ok, client.token} {:error, %OAuth2.Response{body: body}} -> {:error, body} diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex index d359aca0982..04f8e6a826a 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -113,6 +113,12 @@ defmodule Lightning.Config do |> Keyword.get(key) end + @impl true + def github_oauth(key) do + Application.get_env(:lightning, :github_oauth, []) + |> Keyword.get(key) + end + @impl true def check_flag?(flag) do Application.get_env(:lightning, flag) @@ -454,6 +460,7 @@ defmodule Lightning.Config do @callback env() :: :dev | :test | :prod @callback get_extension_mod(key :: atom()) :: any() @callback google(key :: atom()) :: any() + @callback github_oauth(key :: atom()) :: any() @callback grace_period() :: integer() @callback instance_admin_email() :: String.t() @callback kafka_alternate_storage_enabled?() :: boolean() @@ -607,6 +614,10 @@ defmodule Lightning.Config do impl().google(key) end + def github_oauth(key) do + impl().github_oauth(key) + end + def cors_origin do impl().cors_origin() end diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 058437332d3..534c936564a 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -507,6 +507,23 @@ defmodule Lightning.Config.Bootstrap do cors_origin: env!("CORS_ORIGIN", :string, "*") |> String.split(",") |> List.wrap() + github_client_id = env!("GITHUB_CLIENT_ID", :string, nil) + github_client_secret = env!("GITHUB_CLIENT_SECRET", :string, nil) + + if github_client_id && github_client_secret do + github_redirect_uri = + if url_port in [80, 443] do + "#{url_scheme}://#{host}/authenticate/github/callback" + else + "#{url_scheme}://#{host}:#{url_port}/authenticate/github/callback" + end + + config :lightning, :github_oauth, + client_id: github_client_id, + client_secret: github_client_secret, + redirect_uri: github_redirect_uri + end + if config_env() == :prod do unless database_url do raise """ diff --git a/lib/lightning_web/controllers/oidc_controller.ex b/lib/lightning_web/controllers/oidc_controller.ex index 42f04da94a5..a72f9a75334 100644 --- a/lib/lightning_web/controllers/oidc_controller.ex +++ b/lib/lightning_web/controllers/oidc_controller.ex @@ -31,10 +31,8 @@ defmodule LightningWeb.OidcController do """ def new(conn, %{"provider" => provider, "code" => code}) do with {:ok, handler} <- AuthProviders.get_handler(provider), - {:ok, token} <- Handler.get_token(handler, code) do - userinfo = Handler.get_userinfo(handler, token) - email = Map.fetch!(userinfo, "email") - + {:ok, token} <- Handler.get_token(handler, code), + {:ok, email} <- fetch_email(handler, token) do case Accounts.get_user_by_email(email) do nil -> conn @@ -57,6 +55,19 @@ defmodule LightningWeb.OidcController do "remember_me" => "true" }) end + else + {:error, :no_email} -> + conn + |> put_flash( + :error, + "Could not retrieve your email from the provider. Please ensure your email address is accessible." + ) + |> redirect(to: Routes.user_session_path(conn, :new)) + + {:error, _reason} -> + conn + |> put_flash(:error, "Authentication failed") + |> redirect(to: Routes.user_session_path(conn, :new)) end end @@ -70,6 +81,27 @@ defmodule LightningWeb.OidcController do close_browser_window(conn) end + defp fetch_email(handler, token) do + userinfo = Handler.get_userinfo(handler, token) + + case extract_email(userinfo) do + nil -> {:error, :no_email} + email -> {:ok, email} + end + end + + defp extract_email(userinfo) when is_list(userinfo) do + userinfo + |> Enum.find(& &1["primary"]) + |> case do + %{"email" => email} when is_binary(email) -> email + _ -> nil + end + end + + defp extract_email(%{"email" => email}) when is_binary(email), do: email + defp extract_email(_), do: nil + defp broadcast_message(state, data) do [subscription_id, mod, component_id, current_tab] = OauthCredentialHelper.decode_state(state) diff --git a/lib/lightning_web/controllers/user_registration_controller.ex b/lib/lightning_web/controllers/user_registration_controller.ex index eb5508f4618..77e50c08357 100644 --- a/lib/lightning_web/controllers/user_registration_controller.ex +++ b/lib/lightning_web/controllers/user_registration_controller.ex @@ -7,7 +7,10 @@ defmodule LightningWeb.UserRegistrationController do def new(conn, _params) do changeset = Accounts.change_user_registration() - render(conn, "new.html", changeset: changeset) + render(conn, "new.html", + changeset: changeset, + auth_providers: LightningWeb.UserSessionController.auth_providers() + ) end def create(conn, %{"user" => user_params}) do @@ -21,7 +24,10 @@ defmodule LightningWeb.UserRegistrationController do |> redirect_user(user) {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) + render(conn, "new.html", + changeset: changeset, + auth_providers: LightningWeb.UserSessionController.auth_providers() + ) end end diff --git a/lib/lightning_web/controllers/user_registration_html/new.html.heex b/lib/lightning_web/controllers/user_registration_html/new.html.heex index 46591056c66..d1b56e9f2e4 100644 --- a/lib/lightning_web/controllers/user_registration_html/new.html.heex +++ b/lib/lightning_web/controllers/user_registration_html/new.html.heex @@ -89,6 +89,37 @@ <.button type="submit" theme="primary"> Register + <%= if @auth_providers != [] do %> +
+ or +
+ <%= for provider <- @auth_providers do %> + <.button theme="secondary"> + +
+ <%= if provider.name == "github" do %> + + <% else %> + <.icon + name="hero-identification" + class="h-4 w-4 inline-block" + /> + <% end %> + + Sign up with {String.capitalize(provider.name)} + +
+
+ + <% end %> + <% end %> diff --git a/lib/lightning_web/controllers/user_session_controller.ex b/lib/lightning_web/controllers/user_session_controller.ex index 138a048d51e..1888d7bfde8 100644 --- a/lib/lightning_web/controllers/user_session_controller.ex +++ b/lib/lightning_web/controllers/user_session_controller.ex @@ -8,7 +8,7 @@ defmodule LightningWeb.UserSessionController do def new(conn, _params) do render(conn, "new.html", error_message: nil, - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) end @@ -21,7 +21,7 @@ defmodule LightningWeb.UserSessionController do conn |> put_flash(:error, "This user account is disabled") |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) %User{scheduled_deletion: x} when x != nil -> @@ -31,7 +31,7 @@ defmodule LightningWeb.UserSessionController do "This user account is scheduled for deletion" ) |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) %User{mfa_enabled: true} = user -> @@ -51,7 +51,7 @@ defmodule LightningWeb.UserSessionController do conn |> put_flash(:error, "Invalid email or password") |> render("new.html", - auth_handler_url: auth_handler_url() + auth_providers: auth_providers() ) end end @@ -76,13 +76,18 @@ defmodule LightningWeb.UserSessionController do |> UserAuth.log_out_user() end - def auth_handler_url do + def auth_providers do case Lightning.AuthProviders.get_handlers() do {:ok, []} -> - nil + [] - {:ok, [handler | _rest]} -> - Lightning.AuthProviders.get_authorize_url(handler) + {:ok, handlers} -> + Enum.map(handlers, fn handler -> + %{ + name: handler.name, + url: Lightning.AuthProviders.get_authorize_url(handler) + } + end) end end end diff --git a/lib/lightning_web/controllers/user_session_html/new.html.heex b/lib/lightning_web/controllers/user_session_html/new.html.heex index 8e2949b9f8c..168d2f27c7f 100644 --- a/lib/lightning_web/controllers/user_session_html/new.html.heex +++ b/lib/lightning_web/controllers/user_session_html/new.html.heex @@ -53,23 +53,36 @@ <.button type="submit" theme="primary"> Log in - <%= if @auth_handler_url do %> + <%= if @auth_providers != [] do %>
or
- <.button theme="secondary"> - -
- <.icon - name="hero-identification" - class="h-4 w-4 inline-block" - /> - - via external provider - -
-
- + <%= for provider <- @auth_providers do %> + <.button theme="secondary"> + +
+ <%= if provider.name == "github" do %> + + <% else %> + <.icon + name="hero-identification" + class="h-4 w-4 inline-block" + /> + <% end %> + + Sign in with {String.capitalize(provider.name)} + +
+
+ + <% end %> <% end %>