diff --git a/.env.example b/.env.example
index 3cea5779d89..518718d2d2e 100644
--- a/.env.example
+++ b/.env.example
@@ -13,9 +13,13 @@
# SALESFORCE_CLIENT_ID=3MVG9_ghE
# SALESFORCE_CLIENT_SECRET=703777B
-# Set this up to handle Google OAuth credentials (ex: GoogleSheets)
-# GOOGLE_CLIENT_ID=660274980707
-# GOOGLE_CLIENT_SECRET=GOCSPX-ua
+# Set GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET to enable GitHub SSO sign-in
+# GITHUB_CLIENT_ID=
+# GITHUB_CLIENT_SECRET=
+
+# Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable Google SSO sign-in
+# GOOGLE_CLIENT_ID=
+# GOOGLE_CLIENT_SECRET=
# Choose an admin email address and configure a mailer. If you don't specify
# mailer details the local test adaptor will be used and mail previews can be
diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex
index 4787db4be93..f2bec9fa7d6 100644
--- a/lib/lightning/accounts.ex
+++ b/lib/lightning/accounts.ex
@@ -14,6 +14,7 @@ defmodule Lightning.Accounts do
alias Lightning.Accounts.Events
alias Lightning.Accounts.User
alias Lightning.Accounts.UserBackupCode
+ alias Lightning.Accounts.UserIdentity
alias Lightning.Accounts.UserNotifier
alias Lightning.Accounts.UserToken
alias Lightning.Accounts.UserTOTP
@@ -172,7 +173,126 @@ defmodule Lightning.Accounts do
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
- if User.valid_password?(user, password), do: user
+
+ cond do
+ is_nil(user) ->
+ User.valid_password?(user, password)
+ nil
+
+ is_nil(user.hashed_password) ->
+ User.valid_password?(user, password)
+ {:error, :sso_account}
+
+ User.valid_password?(user, password) ->
+ user
+
+ true ->
+ nil
+ end
+ end
+
+ @doc """
+ Looks up a user by their SSO provider identity.
+
+ Returns the `%User{}` if found, otherwise `nil`.
+ """
+ def get_user_by_identity(provider, uid) do
+ from(u in User,
+ join: i in UserIdentity,
+ on: i.user_id == u.id,
+ where: i.provider == ^provider and i.uid == ^uid
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Links an SSO provider identity to an existing user account.
+
+ Silently succeeds if the identity already exists (on_conflict: :nothing).
+ """
+ def link_user_identity(%User{id: user_id}, provider, uid) do
+ %UserIdentity{}
+ |> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid})
+ |> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid])
+ end
+
+ @doc """
+ Returns the SSO identities linked to a user, ordered by provider name.
+ """
+ def list_user_identities(%User{id: user_id}) do
+ from(i in UserIdentity,
+ where: i.user_id == ^user_id,
+ order_by: [asc: i.provider]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a user's identity for a given provider, or `nil` if not linked.
+ """
+ def get_user_identity(%User{id: user_id}, provider) do
+ Repo.get_by(UserIdentity, user_id: user_id, provider: provider)
+ end
+
+ @doc """
+ Removes the SSO identity for the given user and provider.
+
+ Refuses to remove the last identity for an SSO-only user (no password set),
+ since that would lock them out. Such users can set a password by going
+ through the password reset flow first.
+
+ Returns:
+ * `{:ok, identity}` when the identity is removed
+ * `{:error, :not_linked}` when the user has no identity for the provider
+ * `{:error, :would_lock_out}` when removing would leave an SSO-only user
+ with no way to log in
+ """
+ def unlink_user_identity(%User{} = user, provider) do
+ case get_user_identity(user, provider) do
+ nil ->
+ {:error, :not_linked}
+
+ %UserIdentity{} = identity ->
+ if can_remove_identity?(user, identity) do
+ Repo.delete(identity)
+ else
+ {:error, :would_lock_out}
+ end
+ end
+ end
+
+ defp can_remove_identity?(%User{hashed_password: hp}, _identity)
+ when is_binary(hp),
+ do: true
+
+ defp can_remove_identity?(%User{} = user, %UserIdentity{id: identity_id}) do
+ other_count =
+ from(i in UserIdentity,
+ where: i.user_id == ^user.id and i.id != ^identity_id,
+ select: count(i.id)
+ )
+ |> Repo.one()
+
+ other_count > 0
+ end
+
+ @doc """
+ Registers a brand-new user via SSO.
+
+ The user is created without a password and confirmed immediately.
+ A `user_registered` event is broadcast on success.
+ """
+ def register_user_from_sso(attrs, provider, uid) do
+ attrs = Map.put(attrs, :sso_identity, %{provider: provider, uid: uid})
+
+ Repo.transact(fn ->
+ AccountHook.handle_register_user(attrs)
+ end)
+ |> tap(fn result ->
+ with {:ok, user} <- result do
+ Events.user_registered(user)
+ end
+ end)
end
@doc """
diff --git a/lib/lightning/accounts/user.ex b/lib/lightning/accounts/user.ex
index f7670cc2947..082a4804b17 100644
--- a/lib/lightning/accounts/user.ex
+++ b/lib/lightning/accounts/user.ex
@@ -50,6 +50,8 @@ defmodule Lightning.Accounts.User do
has_many :backup_codes, Lightning.Accounts.UserBackupCode,
on_replace: :delete
+ has_many :user_identities, Lightning.Accounts.UserIdentity
+
timestamps()
end
@@ -337,6 +339,22 @@ defmodule Lightning.Accounts.User do
change(user, confirmed_at: now)
end
+ @doc """
+ A changeset for registering a user via SSO. No password is required;
+ the account is confirmed immediately at registration time.
+ """
+ def sso_registration_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:first_name, :last_name, :email])
+ |> validate_required([:first_name, :last_name, :email])
+ |> validate_email_format()
+ |> validate_email_exists()
+ |> put_change(
+ :confirmed_at,
+ DateTime.utc_now() |> DateTime.truncate(:second)
+ )
+ end
+
@spec remove_github_token_changeset(t()) :: Ecto.Changeset.t()
def remove_github_token_changeset(user) do
change(user, github_oauth_token: nil)
diff --git a/lib/lightning/accounts/user_identity.ex b/lib/lightning/accounts/user_identity.ex
new file mode 100644
index 00000000000..1514e1f3b22
--- /dev/null
+++ b/lib/lightning/accounts/user_identity.ex
@@ -0,0 +1,25 @@
+defmodule Lightning.Accounts.UserIdentity do
+ @moduledoc """
+ Schema for tracking SSO provider identities linked to user accounts.
+
+ A user may have multiple identities (one per SSO provider). The combination
+ of provider and uid is globally unique.
+ """
+ use Lightning.Schema
+
+ alias Lightning.Accounts.User
+
+ schema "user_identities" do
+ field :provider, :string
+ field :uid, :string
+ belongs_to :user, User
+ timestamps()
+ end
+
+ def changeset(identity, attrs) do
+ identity
+ |> cast(attrs, [:provider, :uid, :user_id])
+ |> validate_required([:provider, :uid, :user_id])
+ |> unique_constraint([:provider, :uid])
+ end
+end
diff --git a/lib/lightning/auth_providers/cache_warmer.ex b/lib/lightning/auth_providers/cache_warmer.ex
index 21881a7796f..19d6d8d63c7 100644
--- a/lib/lightning/auth_providers/cache_warmer.ex
+++ b/lib/lightning/auth_providers/cache_warmer.ex
@@ -10,6 +10,14 @@ defmodule Lightning.AuthProviders.CacheWarmer do
# https://github.com/whitfin/cachex/issues/276
@dialyzer {:nowarn_function, init: 1}
+ # `GithubHandler.build/0` and `GoogleHandler.build/0` read their client
+ # credentials through `Lightning.Config.*_oauth/1`, which dispatches
+ # dynamically through an extension module. Dialyzer can't see that the
+ # binary branch is reachable, so it concludes `build/0` only ever returns
+ # `{:error, :not_configured}` and flags the `{:ok, _}` pattern here as
+ # unreachable. The runtime behaviour is fine.
+ @dialyzer {:nowarn_function, execute: 1}
+
@doc """
Returns the interval for this warmer.
"""
@@ -20,12 +28,34 @@ 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
+
+ google_entries =
+ case Lightning.AuthProviders.GoogleHandler.build() do
+ {:ok, handler} -> [{handler.name, handler}]
+ _ -> []
+ end
+
+ case db_entries ++ github_entries ++ google_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..f43c308dbcd
--- /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"
+
+ 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: "read:user user:email"
+ )
+ else
+ {:error, :not_configured}
+ end
+ end
+end
diff --git a/lib/lightning/auth_providers/google_handler.ex b/lib/lightning/auth_providers/google_handler.ex
new file mode 100644
index 00000000000..7b54f222e80
--- /dev/null
+++ b/lib/lightning/auth_providers/google_handler.ex
@@ -0,0 +1,42 @@
+defmodule Lightning.AuthProviders.GoogleHandler do
+ @moduledoc """
+ Builds a Handler for Google OAuth2 SSO login from environment configuration.
+
+ Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to enable Google login.
+ """
+
+ alias Lightning.AuthProviders.Handler
+ alias Lightning.AuthProviders.WellKnown
+
+ @name "google"
+ @authorization_endpoint "https://accounts.google.com/o/oauth2/v2/auth"
+ @token_endpoint "https://oauth2.googleapis.com/token"
+ @userinfo_endpoint "https://openidconnect.googleapis.com/v1/userinfo"
+
+ def handler_name, do: @name
+
+ @spec build() :: {:ok, Handler.t()} | {:error, :not_configured}
+ def build do
+ client_id = Lightning.Config.google_oauth(:client_id)
+ client_secret = Lightning.Config.google_oauth(:client_secret)
+ redirect_uri = Lightning.Config.google_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: "openid email profile"
+ )
+ 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 67ba7a744d9..5e4c0f6cab5 100644
--- a/lib/lightning/config.ex
+++ b/lib/lightning/config.ex
@@ -113,6 +113,18 @@ 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 google_oauth(key) do
+ Application.get_env(:lightning, :google_oauth, [])
+ |> Keyword.get(key)
+ end
+
@impl true
def check_flag?(flag) do
Application.get_env(:lightning, flag)
@@ -459,6 +471,8 @@ 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 google_oauth(key :: atom()) :: any()
@callback grace_period() :: integer()
@callback instance_admin_email() :: String.t()
@callback kafka_alternate_storage_enabled?() :: boolean()
@@ -613,6 +627,14 @@ defmodule Lightning.Config do
impl().google(key)
end
+ def github_oauth(key) do
+ impl().github_oauth(key)
+ end
+
+ def google_oauth(key) do
+ impl().google_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 cb7003eb39b..4d14c746ad3 100644
--- a/lib/lightning/config/bootstrap.ex
+++ b/lib/lightning/config/bootstrap.ex
@@ -503,6 +503,32 @@ 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 =
+ sso_redirect_uri(url_scheme, host, url_port, "github")
+
+ config :lightning, :github_oauth,
+ client_id: github_client_id,
+ client_secret: github_client_secret,
+ redirect_uri: github_redirect_uri
+ end
+
+ google_client_id = env!("GOOGLE_CLIENT_ID", :string, nil)
+ google_client_secret = env!("GOOGLE_CLIENT_SECRET", :string, nil)
+
+ if google_client_id && google_client_secret do
+ google_redirect_uri =
+ sso_redirect_uri(url_scheme, host, url_port, "google")
+
+ config :lightning, :google_oauth,
+ client_id: google_client_id,
+ client_secret: google_client_secret,
+ redirect_uri: google_redirect_uri
+ end
+
if config_env() == :prod do
unless database_url do
raise """
@@ -1013,6 +1039,14 @@ defmodule Lightning.Config.Bootstrap do
{:error, worker_key_error("could not be parsed: #{Exception.message(e)}")}
end
+ defp sso_redirect_uri(url_scheme, host, url_port, provider) do
+ if url_port in [80, 443] do
+ "#{url_scheme}://#{host}/authenticate/#{provider}/callback"
+ else
+ "#{url_scheme}://#{host}:#{url_port}/authenticate/#{provider}/callback"
+ end
+ end
+
defp worker_key_error(reason) do
"""
WORKER_RUNS_PRIVATE_KEY #{reason}
diff --git a/lib/lightning/extensions/account_hook.ex b/lib/lightning/extensions/account_hook.ex
index d10bdade6a4..bc192cc047b 100644
--- a/lib/lightning/extensions/account_hook.ex
+++ b/lib/lightning/extensions/account_hook.ex
@@ -4,9 +4,24 @@ defmodule Lightning.Extensions.AccountHook do
alias Ecto.Changeset
alias Lightning.Accounts.User
+ alias Lightning.Accounts.UserIdentity
alias Lightning.Repo
@spec handle_register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def handle_register_user(
+ %{sso_identity: %{provider: provider, uid: uid}} = attrs
+ ) do
+ attrs = Map.delete(attrs, :sso_identity)
+
+ with {:ok, user} <-
+ %User{}
+ |> User.sso_registration_changeset(attrs)
+ |> Repo.insert(),
+ {:ok, _identity} <- link_identity(user, provider, uid) do
+ {:ok, user}
+ end
+ end
+
def handle_register_user(attrs) do
with {:ok, data} <-
User.user_registration_changeset(attrs)
@@ -31,4 +46,10 @@ defmodule Lightning.Extensions.AccountHook do
|> User.changeset(attrs)
|> Repo.insert()
end
+
+ defp link_identity(%User{id: user_id}, provider, uid) do
+ %UserIdentity{}
+ |> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid})
+ |> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid])
+ end
end
diff --git a/lib/lightning_web/components/sso_icons.ex b/lib/lightning_web/components/sso_icons.ex
new file mode 100644
index 00000000000..38a947b64aa
--- /dev/null
+++ b/lib/lightning_web/components/sso_icons.ex
@@ -0,0 +1,47 @@
+defmodule LightningWeb.Components.SsoIcons do
+ @moduledoc """
+ SVG icons for SSO providers, shared between sign-in, sign-up, and the profile
+ identities section.
+ """
+ use LightningWeb, :component
+
+ attr :name, :string, required: true
+ attr :class, :string, default: "h-4 w-4 inline-block"
+
+ def provider_icon(%{name: "github"} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ def provider_icon(%{name: "google"} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ def provider_icon(assigns) do
+ ~H"""
+ <.icon name="hero-identification" class={@class} />
+ """
+ end
+end
diff --git a/lib/lightning_web/controllers/oidc_controller.ex b/lib/lightning_web/controllers/oidc_controller.ex
index 42f04da94a5..7feb724bb3f 100644
--- a/lib/lightning_web/controllers/oidc_controller.ex
+++ b/lib/lightning_web/controllers/oidc_controller.ex
@@ -2,6 +2,7 @@ defmodule LightningWeb.OidcController do
use LightningWeb, :controller
alias Lightning.Accounts
+ alias Lightning.Accounts.User
alias Lightning.AuthProviders
alias Lightning.AuthProviders.Handler
alias LightningWeb.OauthCredentialHelper
@@ -9,6 +10,9 @@ defmodule LightningWeb.OidcController do
action_fallback LightningWeb.FallbackController
+ @link_intent_session_key :sso_link_intent_provider
+ @pending_signup_session_key :sso_pending_signup
+
plug :fetch_current_user
@doc """
@@ -25,38 +29,54 @@ defmodule LightningWeb.OidcController do
end
end
+ @doc """
+ Initiates an SSO link flow for an already-authenticated user. The
+ provider is recorded in the session and the user is redirected to the
+ provider's authorize URL. On callback the controller links the resulting
+ identity to the current account rather than logging in.
+ """
+ def link(conn, %{"provider" => provider}) do
+ with {:ok, handler} <- AuthProviders.get_handler(provider) do
+ if conn.assigns.current_user do
+ conn
+ |> put_session(@link_intent_session_key, provider)
+ |> redirect(external: Handler.authorize_url(handler))
+ else
+ redirect(conn, to: Routes.user_session_path(conn, :new))
+ end
+ end
+ end
+
@doc """
Once the user has completed the authorization flow from above, they are
returned here, and the authorization code is used to log them in.
"""
def new(conn, %{"provider" => provider, "code" => code}) do
+ {link_intent, conn} = pop_link_intent(conn)
+
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")
-
- case Accounts.get_user_by_email(email) do
- nil ->
- conn
- |> put_flash(:error, "Could not find user account")
- |> redirect(to: Routes.user_session_path(conn, :new))
-
- %{mfa_enabled: true} = user ->
- conn
- |> UserAuth.log_in_user(user)
- |> UserAuth.mark_totp_pending()
- |> redirect(
- to:
- Routes.user_totp_path(conn, :new, user: %{"remember_me" => "true"})
- )
-
- user ->
- conn
- |> UserAuth.log_in_user(user)
- |> UserAuth.redirect_with_return_to(%{
- "remember_me" => "true"
- })
+ {:ok, token} <- Handler.get_token(handler, code),
+ userinfo <- Handler.get_userinfo(handler, token),
+ {:ok, email} <- fetch_email(userinfo),
+ {:ok, uid} <- fetch_uid(userinfo) do
+ if link_intent == provider && conn.assigns.current_user do
+ handle_sso_link(conn, conn.assigns.current_user, provider, uid)
+ else
+ handle_sso_login(conn, provider, uid, email, userinfo)
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: failure_redirect(conn, link_intent))
+
+ {:error, _reason} ->
+ conn
+ |> put_flash(:error, "Authentication failed")
+ |> redirect(to: failure_redirect(conn, link_intent))
end
end
@@ -70,6 +90,225 @@ defmodule LightningWeb.OidcController do
close_browser_window(conn)
end
+ @doc """
+ Renders the confirmation page shown after a successful SSO callback that
+ would create a brand-new account. We ask the user to confirm before
+ provisioning so they aren't surprised by an account they didn't realise was
+ being created.
+ """
+ def confirm_signup(conn, _params) do
+ case get_session(conn, @pending_signup_session_key) do
+ %{} = pending ->
+ render(conn, :confirm_signup, pending: pending)
+
+ _ ->
+ conn
+ |> clear_pending_signup()
+ |> put_flash(:error, "No pending sign-up to confirm.")
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ end
+ end
+
+ @doc """
+ Confirms a pending SSO signup. Creates the account, links the identity, and
+ logs the user in.
+ """
+ def complete_signup(conn, _params) do
+ case get_session(conn, @pending_signup_session_key) do
+ %{
+ "provider" => provider,
+ "uid" => uid,
+ "email" => email,
+ "first_name" => first_name,
+ "last_name" => last_name
+ } ->
+ attrs = %{
+ email: email,
+ first_name: first_name,
+ last_name: last_name
+ }
+
+ case Accounts.register_user_from_sso(attrs, provider, uid) do
+ {:ok, user} ->
+ conn
+ |> clear_pending_signup()
+ |> do_log_in(user)
+
+ {:error, _changeset} ->
+ conn
+ |> clear_pending_signup()
+ |> put_flash(
+ :error,
+ "Could not create your account. Please try again."
+ )
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ end
+
+ _ ->
+ conn
+ |> clear_pending_signup()
+ |> put_flash(:error, "No pending sign-up to confirm.")
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ end
+ end
+
+ @doc """
+ Cancels a pending SSO signup, clearing the stashed state.
+ """
+ def cancel_signup(conn, _params) do
+ conn
+ |> clear_pending_signup()
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ end
+
+ defp clear_pending_signup(conn) do
+ delete_session(conn, @pending_signup_session_key)
+ end
+
+ defp handle_sso_link(conn, %User{} = current_user, provider, uid) do
+ case Accounts.get_user_by_identity(provider, uid) do
+ %User{id: id} when id == current_user.id ->
+ conn
+ |> put_flash(
+ :info,
+ "Your #{display_name(provider)} account is already linked."
+ )
+ |> redirect(to: ~p"/profile")
+
+ %User{} ->
+ conn
+ |> put_flash(
+ :error,
+ "This #{display_name(provider)} identity is already linked to a different account."
+ )
+ |> redirect(to: ~p"/profile")
+
+ nil ->
+ case Accounts.link_user_identity(current_user, provider, uid) do
+ {:ok, _identity} ->
+ conn
+ |> put_flash(
+ :info,
+ "Linked your #{display_name(provider)} account."
+ )
+ |> redirect(to: ~p"/profile")
+
+ {:error, _reason} ->
+ conn
+ |> put_flash(
+ :error,
+ "Could not link your #{display_name(provider)} account. Please try again."
+ )
+ |> redirect(to: ~p"/profile")
+ end
+ end
+ end
+
+ defp handle_sso_login(conn, provider, uid, email, userinfo) do
+ case Accounts.get_user_by_identity(provider, uid) do
+ %User{} = user ->
+ do_log_in(conn, user)
+
+ nil ->
+ case Accounts.get_user_by_email(email) do
+ %User{} ->
+ conn
+ |> put_flash(
+ :info,
+ "An account already exists for #{email}. Sign in and link your #{display_name(provider)} account from your profile settings to use single sign-on."
+ )
+ |> redirect(to: Routes.user_session_path(conn, :new))
+
+ nil ->
+ request_signup_confirmation(conn, provider, uid, email, userinfo)
+ end
+ end
+ end
+
+ defp request_signup_confirmation(conn, provider, uid, email, userinfo) do
+ %{first_name: first_name, last_name: last_name} = extract_name(userinfo)
+
+ pending = %{
+ "provider" => provider,
+ "uid" => uid,
+ "email" => email,
+ "first_name" => first_name,
+ "last_name" => last_name
+ }
+
+ conn
+ |> put_session(@pending_signup_session_key, pending)
+ |> redirect(to: ~p"/authenticate/signup/confirm")
+ end
+
+ defp do_log_in(conn, %{mfa_enabled: true} = user) do
+ conn
+ |> UserAuth.log_in_user(user)
+ |> UserAuth.mark_totp_pending()
+ |> redirect(
+ to: Routes.user_totp_path(conn, :new, user: %{"remember_me" => "true"})
+ )
+ end
+
+ defp do_log_in(conn, user) do
+ conn
+ |> UserAuth.log_in_user(user)
+ |> UserAuth.redirect_with_return_to(%{"remember_me" => "true"})
+ end
+
+ defp fetch_email(userinfo) do
+ case extract_email(userinfo) do
+ nil -> {:error, :no_email}
+ email -> {:ok, email}
+ end
+ end
+
+ defp fetch_uid(userinfo) do
+ case extract_uid(userinfo) do
+ nil -> {:error, :no_uid}
+ uid -> {:ok, uid}
+ end
+ end
+
+ defp extract_email(%{"email" => email}) when is_binary(email), do: email
+ defp extract_email(_), do: nil
+
+ defp extract_uid(%{"sub" => sub}) when is_binary(sub), do: sub
+ defp extract_uid(%{"id" => id}) when is_integer(id), do: to_string(id)
+ defp extract_uid(%{"id" => id}) when is_binary(id), do: id
+ defp extract_uid(_), do: nil
+
+ defp extract_name(%{"given_name" => first, "family_name" => last})
+ when is_binary(first) and is_binary(last) do
+ %{first_name: first, last_name: last}
+ end
+
+ defp extract_name(%{"name" => name}) when is_binary(name) do
+ case String.split(name, " ", parts: 2) do
+ [first, last] -> %{first_name: first, last_name: last}
+ [first] -> %{first_name: first, last_name: ""}
+ end
+ end
+
+ defp extract_name(_), do: %{first_name: "", last_name: ""}
+
+ defp pop_link_intent(conn) do
+ case get_session(conn, @link_intent_session_key) do
+ nil -> {nil, conn}
+ provider -> {provider, delete_session(conn, @link_intent_session_key)}
+ end
+ end
+
+ defp failure_redirect(conn, link_intent) do
+ if link_intent && conn.assigns.current_user do
+ ~p"/profile"
+ else
+ Routes.user_session_path(conn, :new)
+ end
+ end
+
+ defp display_name(provider), do: String.capitalize(provider)
+
defp broadcast_message(state, data) do
[subscription_id, mod, component_id, current_tab] =
OauthCredentialHelper.decode_state(state)
diff --git a/lib/lightning_web/controllers/oidc_html.ex b/lib/lightning_web/controllers/oidc_html.ex
new file mode 100644
index 00000000000..8e47ad2153f
--- /dev/null
+++ b/lib/lightning_web/controllers/oidc_html.ex
@@ -0,0 +1,7 @@
+defmodule LightningWeb.OidcHTML do
+ @moduledoc false
+
+ use LightningWeb, :html
+
+ embed_templates "oidc_html/*"
+end
diff --git a/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex b/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex
new file mode 100644
index 00000000000..37e36a21434
--- /dev/null
+++ b/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex
@@ -0,0 +1,64 @@
+
+ No account exists for {@pending["email"]}
+ yet. We'll create a new account using your
+ {String.capitalize(@pending["provider"])}
+ profile.
+
+
+
+