diff --git a/.env.example b/.env.example index eaa0d9f2f11..07e93c5b163 100644 --- a/.env.example +++ b/.env.example @@ -9,13 +9,13 @@ # GITHUB_APP_ID=12345 # GITHUB_CERT=Base64-encoded-private-key -# Set this up to handle SalesForce OAuth credentials -# SALESFORCE_CLIENT_ID=3MVG9_ghE -# SALESFORCE_CLIENT_SECRET=703777B +# Set SSO_GITHUB_CLIENT_ID/SSO_GITHUB_CLIENT_SECRET to enable GitHub SSO sign-in +# SSO_GITHUB_CLIENT_ID= +# SSO_GITHUB_CLIENT_SECRET= -# Set this up to handle Google OAuth credentials (ex: GoogleSheets) -# GOOGLE_CLIENT_ID=660274980707 -# GOOGLE_CLIENT_SECRET=GOCSPX-ua +# Set SSO_GOOGLE_CLIENT_ID/SSO_GOOGLE_CLIENT_SECRET to enable Google SSO sign-in +# SSO_GOOGLE_CLIENT_ID= +# SSO_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/CHANGELOG.md b/CHANGELOG.md index df9049127a3..67e713f0434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to ### Added +- Single Sign-On (SSO) sign-in with GitHub and Google. Users can sign in with an + external identity provider and link or unlink providers from their profile + settings. [#4621](https://github.com/OpenFn/lightning/issues/4621) - Support a comma-separated list of paths in `OPENFN_ADAPTORS_REPO`, merging multiple local adaptor repos in precedence order (earlier paths win on name collisions, and shadowed entries are logged). Lets a private repo override or diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4861f89b045..4a8b63e3045 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -360,38 +360,61 @@ been marked as recovered. Once all files have either been recovered or discarded, the triggers can be enabled once more. -### Google Oauth2 - -Using your Google Cloud account, provision a new OAuth 2.0 Client with the 'Web -application' type. - -Set the callback url to: `https:///authenticate/callback`. -Replacing `ENDPOINT DOMAIN` with the host name of your instance. - -Once the client has been created, get/download the OAuth client JSON and set the -following environment variables: - -| **Variable** | Description | -| ---------------------- | --------------------------------------------- | -| `GOOGLE_CLIENT_ID` | Which is `client_id` from the client details. | -| `GOOGLE_CLIENT_SECRET` | `client_secret` from the client details. | - -### Salesforce Oauth2 - -Using your Salesforce developer account, create a new Oauth 2.0 connected -application. - -Set the callback url to: `https:///authenticate/callback`. -Replacing `ENDPOINT DOMAIN` with the host name of your instance. - -Grant permissions as desired. - -Once the client has been created set the following environment variables: - -| **Variable** | Description | -| -------------------------- | --------------------------------------------------------------------- | -| `SALESFORCE_CLIENT_ID` | Which is `Consumer Key` from the "Manage Consumer Details" screen. | -| `SALESFORCE_CLIENT_SECRET` | Which is `Consumer Secret` from the "Manage Consumer Details" screen. | +### Single Sign-On (SSO) + +Lightning supports SSO **sign-in** with GitHub and Google, letting users +authenticate with an external identity provider instead of an email/password. + +> SSO sign-in is distinct from two other, similarly-named features. Don't mix up +> their environment variables: +> +> - **GitHub App** (`GITHUB_APP_ID`, `GITHUB_APP_CLIENT_ID`, +> `GITHUB_APP_CLIENT_SECRET`, `GITHUB_CERT`) — used for project **version +> control / repo sync**, with callback `/oauth/github/callback`. See +> [GitHub](#github) above. This is **not** sign-in. +> - **Credential OAuth** — clients that **jobs** use to connect to external +> systems (e.g. Google Sheets, Salesforce). These are configured per-project +> in the UI, not via these environment variables. + +Enable a provider by setting its client ID and secret. Each provider has its own +callback (redirect) URL that you must register in the provider's OAuth app +settings. The redirect URL is derived automatically from your configured +host/scheme/port — you only need to register the matching URL below. + +| Provider | Variables | Redirect / Callback URL | +| -------- | -------------------------------------------------- | -------------------------------------------------------- | +| GitHub | `SSO_GITHUB_CLIENT_ID`, `SSO_GITHUB_CLIENT_SECRET` | `https:///authenticate/github/callback` | +| Google | `SSO_GOOGLE_CLIENT_ID`, `SSO_GOOGLE_CLIENT_SECRET` | `https:///authenticate/google/callback` | + +For **GitHub**, create an **OAuth App** (Settings → Developer settings → OAuth +Apps — _not_ a GitHub App) and request the `read:user` and `user:email` scopes. +GitHub's userinfo endpoint omits the email for users without a public profile +email, so Lightning resolves the primary, verified address via the granted +`user:email` scope. + +For **Google**, provision an OAuth 2.0 Client (type "Web application") and add +the SSO callback above to its authorized redirect URIs. + +> ℹ️ The `SSO_`-prefixed variables are **used only for SSO sign-in** and are +> deliberately distinct from any other feature's settings — create a dedicated +> OAuth client for SSO and register only the SSO callback above. In particular, +> the OAuth clients your **jobs** use to connect to Google Sheets, Salesforce, +> etc. are configured per-project in the UI and are unaffected. + +### OAuth credential connections (Google, Salesforce, etc.) + +OAuth clients that **jobs** use to connect to external systems (Google Sheets, +Salesforce, and similar) are no longer configured via environment variables. +They are registered in the UI under **Credentials → OAuth clients** and scoped +to the projects that use them; the client id, secret, and redirect/callback URL +are entered in that form. + +> The older `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` and +> `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` environment variables are +> no longer read by Lightning and can be removed from your deployment. They are +> unrelated to the [Single Sign-On (SSO)](#single-sign-on-sso) `SSO_`-prefixed +> variables above, which configure user **sign-in** rather than credential +> connections. ### Webhook Retry Configuration diff --git a/lib/lightning/accounts.ex b/lib/lightning/accounts.ex index 4787db4be93..451e7898a01 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,167 @@ 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 a user. + + Idempotent if already linked to the same user. Returns + `{:error, :identity_already_linked}` if the identity is claimed by another + user, and `{:error, :provider_already_linked}` if the user already has a + different identity for this provider (at most one identity per provider). + """ + def link_user_identity(%User{id: user_id} = user, provider, uid) do + case get_identity(provider, uid) do + %UserIdentity{user_id: ^user_id} = identity -> + {:ok, identity} + + %UserIdentity{} -> + {:error, :identity_already_linked} + + nil -> + if provider_linked?(user, provider) do + {:error, :provider_already_linked} + else + %UserIdentity{} + |> UserIdentity.changeset(%{ + user_id: user_id, + provider: provider, + uid: uid + }) + |> Repo.insert() + end + end + end + + defp get_identity(provider, uid) do + Repo.get_by(UserIdentity, provider: provider, uid: uid) + end + + defp provider_linked?(%User{id: user_id}, provider) do + Repo.exists?( + from(i in UserIdentity, + where: i.user_id == ^user_id and i.provider == ^provider + ) + ) + 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 """ + Removes a linked SSO identity, addressed by its id and scoped to `user`. + + 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 no such identity belongs to the user + * `{:error, :would_lock_out}` when removing would leave an SSO-only user + with no way to log in + * `{:error, :delete_failed}` when the identity exists but its deletion + failed at the database level + """ + def unlink_user_identity(%User{} = user, identity_id) do + case Ecto.UUID.cast(identity_id) do + {:ok, identity_id} -> do_unlink_user_identity(user, identity_id) + :error -> {:error, :not_linked} + end + end + + defp do_unlink_user_identity(%User{} = user, identity_id) do + Repo.transaction(fn -> + locked_user = + from(u in User, where: u.id == ^user.id, lock: "FOR UPDATE") + |> Repo.one() + + identity = Repo.get_by(UserIdentity, id: identity_id, user_id: user.id) + + cond do + is_nil(locked_user) or is_nil(identity) -> + Repo.rollback(:not_linked) + + not can_remove_identity?(locked_user, identity) -> + Repo.rollback(:would_lock_out) + + true -> + case Repo.delete(identity) do + {:ok, deleted} -> deleted + {:error, _changeset} -> Repo.rollback(:delete_failed) + end + 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 + Repo.exists?( + from(i in UserIdentity, + where: i.user_id == ^user.id and i.id != ^identity_id + ) + ) + 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 """ @@ -612,7 +773,8 @@ defmodule Lightning.Accounts do defp validate_current_password(changeset, user) do Changeset.validate_change(changeset, :current_password, fn :current_password, password -> - if Bcrypt.verify_pass(password, user.hashed_password) do + if is_binary(user.hashed_password) and + Bcrypt.verify_pass(password, user.hashed_password) do [] else [current_password: "does not match password"] @@ -690,6 +852,35 @@ defmodule Lightning.Accounts do end end + @doc """ + Sets the password for an SSO user that has no local password yet. + + Unlike `update_user_password/3`, this does not require a current password, + since the user never had one. It is guarded so it can only be used on accounts + without an existing password. + + ## Examples + + iex> set_user_password(sso_user, %{password: ...}) + {:ok, %User{}} + + """ + def set_user_password(%User{hashed_password: nil} = user, attrs) do + changeset = User.password_changeset(user, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all( + :tokens, + UserToken.user_and_contexts_query(user, :all) + ) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + @doc """ Given a user and a confirmation email, this function sets a scheduled deletion date based on the PURGE_DELETED_AFTER_DAYS environment variable. If no ENV is diff --git a/lib/lightning/accounts/user.ex b/lib/lightning/accounts/user.ex index 40606c6cbd9..572958bb04c 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 @@ -327,6 +329,21 @@ 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() + |> 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) @@ -362,6 +379,15 @@ defmodule Lightning.Accounts.User do false end + @doc """ + Returns `true` if the user has a local password set. + + SSO-only users have no password (`hashed_password` is `nil`) until they set + one via the password reset flow or the profile page. + """ + def has_password?(%__MODULE__{hashed_password: hashed_password}), + do: is_binary(hashed_password) + @doc """ Validates the current password otherwise adds an error to the changeset. """ diff --git a/lib/lightning/accounts/user_identity.ex b/lib/lightning/accounts/user_identity.ex new file mode 100644 index 00000000000..93c5270edaa --- /dev/null +++ b/lib/lightning/accounts/user_identity.ex @@ -0,0 +1,28 @@ +defmodule Lightning.Accounts.UserIdentity do + @moduledoc """ + Schema for tracking SSO provider identities linked to user accounts. + + A user has at most one identity per provider, and the combination of provider + and uid is globally unique (an identity can't be claimed by two users). + """ + 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]) + |> unique_constraint([:user_id, :provider], + message: "is already linked to a different account for this provider" + ) + end +end diff --git a/lib/lightning/auth_providers.ex b/lib/lightning/auth_providers.ex index 0524e28a4b3..9a94101f96f 100644 --- a/lib/lightning/auth_providers.ex +++ b/lib/lightning/auth_providers.ex @@ -10,6 +10,15 @@ defmodule Lightning.AuthProviders do alias Lightning.AuthProviders.WellKnown alias Lightning.Repo + @doc """ + Returns a human-friendly name for a provider, e.g. `"github"` -> `"GitHub"`. + """ + @spec display_name(provider :: String.t()) :: String.t() + # Explicit clauses needed for providers whose correct name has mid-word + # capitalisation that String.capitalize/1 can't produce. + def display_name("github"), do: "GitHub" + def display_name(provider), do: String.capitalize(provider) + @spec get_existing() :: AuthConfig.t() | nil def get_existing do from(ap in AuthConfig) |> Repo.one() @@ -88,21 +97,6 @@ defmodule Lightning.AuthProviders do Handler.new(name, opts) end - @doc """ - Retrieve the authorization url for a given handler or handler name. - """ - @spec get_authorize_url(String.t() | Handler.t()) :: String.t() | nil - def get_authorize_url(name) when is_binary(name) do - case get_handler(name) do - {:ok, handler} -> get_authorize_url(handler) - {:error, :not_found} -> nil - end - end - - def get_authorize_url(%Handler{} = handler) do - Handler.authorize_url(handler) - end - defp find_and_build(name) do get_existing(name) |> Handler.from_model() diff --git a/lib/lightning/auth_providers/cache_warmer.ex b/lib/lightning/auth_providers/cache_warmer.ex index 21881a7796f..c235b24067c 100644 --- a/lib/lightning/auth_providers/cache_warmer.ex +++ b/lib/lightning/auth_providers/cache_warmer.ex @@ -5,11 +5,21 @@ defmodule Lightning.AuthProviders.CacheWarmer do use Cachex.Warmer alias Lightning.AuthProviders + require Logger + # Suppress dialyzer warning for Cachex.Warmer.init/1 # This has been fixed upstream but not released on hex. # 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 +30,40 @@ 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 + error -> + Logger.warning( + "AuthProviders.CacheWarmer failed to warm the DB-backed provider: " <> + Exception.message(error) + ) + + [] + 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/common.ex b/lib/lightning/auth_providers/common.ex deleted file mode 100644 index 5f2c5cef75f..00000000000 --- a/lib/lightning/auth_providers/common.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule Lightning.AuthProviders.Common do - @moduledoc """ - Provides common functionality for handling OAuth authentication across different providers. - """ - - alias Lightning.AuthProviders.WellKnown - require Logger - - defmodule TokenBody do - @moduledoc """ - Defines a schema for OAuth token information. - """ - - use Lightning.Schema - - @primary_key false - embedded_schema do - field :access_token, :string - field :refresh_token, :string - field :expires_at, :integer - field :scope, :string - field :instance_url, :string - field :sandbox, :boolean, default: false - field :apiVersion, :string, default: nil - end - - @doc """ - Creates a new TokenBody struct with the given attributes. - """ - def new(attrs) do - changeset(attrs) |> apply_changes() - end - - @doc """ - Converts an OAuth2 token to a TokenBody struct. - """ - def from_oauth2_token(token) do - Map.from_struct(token) - |> Map.merge(token.other_params) - |> Enum.into(%{}, fn {key, value} -> - {key |> to_string(), value} - end) - |> new() - end - - @doc false - def changeset(attrs \\ %{}) do - %__MODULE__{} - |> cast(attrs, [ - :access_token, - :refresh_token, - :expires_at, - :scope, - :instance_url, - :sandbox, - :apiVersion - ]) - |> validate_required([:access_token, :refresh_token]) - end - end - - @doc """ - Requests an authentication token from the OAuth provider. - """ - def get_token(client, params), - do: OAuth2.Client.get_token(client, params) - - @doc """ - Refreshes the authentication token using the OAuth provider. - """ - def refresh_token(client, token) do - OAuth2.Client.refresh_token(%{client | token: token}) - |> handle_refresh_token_response() - end - - @doc false - defp handle_refresh_token_response({:ok, %{token: token}}), do: {:ok, token} - - defp handle_refresh_token_response( - {:error, %OAuth2.Response{status_code: code, body: body}} - ), - do: {:error, %{status_code: code, body: body}} - - defp handle_refresh_token_response({:error, %{reason: reason}}), - do: {:error, reason} - - @doc """ - Retrieves user information from the OAuth provider. - """ - def get_userinfo(client, token, wellknown_url) do - {:ok, wellknown} = get_wellknown(wellknown_url) - - OAuth2.Client.get(%{client | token: token}, wellknown.userinfo_endpoint) - end - - @doc """ - Fetches the well-known configuration from the OAuth provider. - """ - def get_wellknown(wellknown_url) do - case Tesla.get(wellknown_url) do - {:ok, %{status: status, body: body}} when status in 200..202 -> - {:ok, Jason.decode!(body) |> WellKnown.new()} - - {:ok, %{status: status}} when status >= 500 -> - {:error, "Received #{status} from #{wellknown_url}"} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Fetches the well-known configuration from the OAuth provider and raises an error if not successful. - """ - def get_wellknown!(wellknown_url) do - get_wellknown(wellknown_url) - |> case do - {:ok, wellknown} -> - wellknown - - {:error, reason} -> - raise reason - end - end - - @doc """ - Builds a new OAuth client with the specified configuration, authorization URL, token URL, and options. - """ - def build_client(provider, wellknown_url, opts \\ []) do - config = Lightning.Config.oauth_provider(provider) - - if is_nil(config) or is_nil(config[:client_id]) or - is_nil(config[:client_secret]) do - Logger.error(""" - Please ensure the CLIENT_ID and CLIENT_SECRET ENV variables are set correctly. - """) - - {:error, :invalid_config} - else - case get_wellknown(wellknown_url) do - {:ok, wellknown} -> - client = - OAuth2.Client.new(strategy: OAuth2.Strategy.AuthCode) - |> OAuth2.Client.put_serializer("application/json", Jason) - |> Map.merge(%{ - authorize_url: wellknown.authorization_endpoint, - token_url: wellknown.token_endpoint, - client_id: config[:client_id], - client_secret: config[:client_secret], - redirect_uri: opts[:callback_url] - }) - - {:ok, client} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Constructs the authorization URL with the given client, state, scopes, and options. - """ - def authorize_url(client, state, scopes, opts \\ []) do - OAuth2.Client.authorize_url!( - client, - opts ++ - [ - scope: Enum.join(scopes, " "), - state: state, - access_type: "offline", - prompt: "consent" - ] - ) - end - - def introspect( - {:ok, %OAuth2.AccessToken{access_token: access_token} = token}, - provider, - wellknown_url - ) do - {:ok, wellknown} = get_wellknown(wellknown_url) - config = Lightning.Config.oauth_provider(provider) - - Tesla.post( - wellknown.introspection_endpoint, - "token=#{access_token}&client_id=#{config[:client_id]}&client_secret=#{config[:client_secret]}&token_type_hint=access_token", - headers: [ - {"Accept", "application/json"}, - {"Content-Type", "application/x-www-form-urlencoded"} - ] - ) - |> handle_introspection_result(token) - end - - def introspect(result, _provider, _wellknow_url), do: result - - defp handle_introspection_result({:ok, %{status: status, body: body}}, token) - when status in 200..202 do - expires_at = Jason.decode!(body) |> Map.get("exp") - updated_token = Map.update!(token, :expires_at, fn _ -> expires_at end) - {:ok, updated_token} - end - - defp handle_introspection_result({:ok, %{status: status}}, _token) - when status not in 200..202, - do: {:error, nil} - - defp handle_introspection_result({:error, _reason}, _token), do: {:error, nil} - - @doc """ - Checks if a token is still valid or must be refreshed. If expires_at is nil, - it will return `false`, forcing a refresh. If the token has already expired or - will expire before the default buffer (in the next 5 minutes) we return - `false`, forcing a refresh. - """ - def still_fresh(token_body, threshold \\ 5, time_unit \\ :minute) - - def still_fresh( - %{expires_at: nil}, - _threshold, - _time_unit - ), - do: false - - def still_fresh( - %{expires_at: expires_at}, - threshold, - time_unit - ) do - current_time = DateTime.utc_now() - expiration_time = DateTime.from_unix!(expires_at) - time_remaining = DateTime.diff(expiration_time, current_time, time_unit) - time_remaining >= threshold - 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..fed4a12d603 --- /dev/null +++ b/lib/lightning/auth_providers/github_handler.ex @@ -0,0 +1,44 @@ +defmodule Lightning.AuthProviders.GithubHandler do + @moduledoc """ + Builds a Handler for GitHub OAuth2 SSO login from environment configuration. + + Set SSO_GITHUB_CLIENT_ID and SSO_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" + @user_emails_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, + user_emails_endpoint: @user_emails_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.ex b/lib/lightning/auth_providers/google.ex deleted file mode 100644 index 6b08c08ea28..00000000000 --- a/lib/lightning/auth_providers/google.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule Lightning.AuthProviders.Google do - @moduledoc """ - Handles the specifics of the Google OAuth authentication process. - """ - @behaviour Lightning.AuthProviders.OAuthBehaviour - - alias Lightning.AuthProviders.Common - require Logger - - def provider_name, do: "Google" - - def scopes, - do: %{ - optional: [], - mandatory: ~W(userinfo.email userinfo.profile spreadsheets) - } - - def scopes_doc_url, - do: "https://developers.google.com/identity/protocols/oauth2/scopes" - - def wellknown_url(_sandbox) do - config = Lightning.Config.oauth_provider(:google) - config[:wellknown_url] - end - - @impl true - def build_client(wellknown_url, opts \\ []) do - Common.build_client(:google, wellknown_url, opts) - end - - @impl true - def authorize_url(client, state, scopes \\ [], opts \\ []) do - scopes = Enum.map(scopes, &urlify_scope/1) - - Common.authorize_url(client, state, scopes, opts) - end - - defp urlify_scope(scope), do: "https://www.googleapis.com/auth/#{scope}" - - @impl true - def get_token(client, _wellknown_url, params) do - Common.get_token(client, params) - end - - @impl true - def refresh_token(client, token, _wellknown_url) do - Common.refresh_token(client, token) - end - - @impl true - def refresh_token(token, wellknown_url) do - {:ok, %OAuth2.Client{} = client} = build_client(wellknown_url) - refresh_token(client, token, wellknown_url) - end - - @impl true - def get_userinfo(client, token, wellknown_url) do - Common.get_userinfo(client, token, wellknown_url) - 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..8bc25d3cda1 --- /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 SSO_GOOGLE_CLIENT_ID and SSO_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..dbe9d8f9adb 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 @@ -78,9 +86,20 @@ defmodule Lightning.AuthProviders.Handler do new(model.name, opts) end - @spec authorize_url(handler :: __MODULE__.t()) :: String.t() - def authorize_url(handler) do - OAuth2.Client.authorize_url!(handler.client, scope: "openid email profile") + @doc """ + Builds the provider authorize URL. + + Extra `params` (e.g. `state:`) are forwarded to the OAuth2 client; the + configured `scope` is always included. Callers should pass an unguessable + `state` to protect the callback against CSRF. + """ + @spec authorize_url(handler :: __MODULE__.t(), params :: keyword()) :: + String.t() + def authorize_url(handler, params \\ []) do + OAuth2.Client.authorize_url!( + handler.client, + Keyword.put(params, :scope, handler.scope) + ) end @spec get_token(handler :: __MODULE__.t(), code :: String.t()) :: @@ -88,7 +107,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} @@ -96,12 +115,78 @@ defmodule Lightning.AuthProviders.Handler do end @spec get_userinfo(handler :: __MODULE__.t(), token :: OAuth2.AccessToken.t()) :: - map() + {:ok, map()} | {:error, term()} def get_userinfo(handler, token) do - OAuth2.Client.get!( - %{handler.client | token: token}, - handler.wellknown.userinfo_endpoint - ).body + client = %{handler.client | token: token} + + case OAuth2.Client.get(client, handler.wellknown.userinfo_endpoint) do + {:ok, %OAuth2.Response{body: userinfo}} -> + {:ok, maybe_resolve_email(client, handler.wellknown, userinfo)} + + {:error, reason} -> + {:error, reason} + end + end + + # Some providers (e.g. GitHub) don't carry a verification status in userinfo. + # The `/user/emails` endpoint is authoritative, so we derive `email_verified` + # from it rather than assuming a present userinfo email is verified. + defp maybe_resolve_email( + client, + %{user_emails_endpoint: endpoint}, + %{} = userinfo + ) + when is_binary(endpoint) do + emails = fetch_emails(client, endpoint) + + if is_binary(userinfo["email"]) do + Map.put(userinfo, "email_verified", verified?(emails, userinfo["email"])) + else + case select_primary_verified_email(emails) do + nil -> + userinfo + + email -> + userinfo + |> Map.put("email", email) + |> Map.put("email_verified", true) + end + end + end + + defp maybe_resolve_email(_client, _wellknown, userinfo), do: userinfo + + defp fetch_emails(client, endpoint) do + case OAuth2.Client.get(client, endpoint) do + {:ok, %OAuth2.Response{body: emails}} when is_list(emails) -> emails + _ -> [] + end + end + + defp verified?(emails, email) do + target = String.downcase(email) + + Enum.any?(emails, fn + %{"verified" => true, "email" => candidate} when is_binary(candidate) -> + String.downcase(candidate) == target + + _ -> + false + end) + end + + defp select_primary_verified_email(emails) do + primary = + Enum.find_value(emails, fn + %{"primary" => true, "verified" => true, "email" => email} -> email + _ -> nil + end) + + primary || + Enum.find_value(emails, fn + %{"verified" => true, "email" => email} -> email + _ -> nil + end) end defp validate_opts(opts) do diff --git a/lib/lightning/auth_providers/oauth_behaviour.ex b/lib/lightning/auth_providers/oauth_behaviour.ex deleted file mode 100644 index fa082bb394f..00000000000 --- a/lib/lightning/auth_providers/oauth_behaviour.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Lightning.AuthProviders.OAuthBehaviour do - @moduledoc """ - Defines a behaviour for OAuth providers within the Lightning application, - specifying a common interface for OAuth operations. - This interface ensures consistency and interoperability among different - authentication providers (e.g., Google, Salesforce) - by defining a set of required functions that each provider must implement. - """ - @callback get_token( - client :: map(), - wellknown_url :: String.t() | nil, - params :: map() - ) :: - {:ok, map()} | {:error, map()} - @callback refresh_token( - client :: map(), - token :: map(), - wellknown_url :: String.t() | nil - ) :: - {:ok, map()} | {:error, map()} - @callback refresh_token( - token :: map(), - wellknown_url :: String.t() | nil - ) :: - {:ok, map()} | {:error, map()} - @callback get_userinfo( - client :: map(), - token :: map(), - wellknown_url :: String.t() - ) :: - {:ok, map()} | {:error, map()} - @callback build_client(opts :: Keyword.t()) :: - {:ok, map()} | {:error, :invalid_config} - @callback authorize_url( - client :: map(), - state :: String.t(), - scopes :: list(String.t()), - opts :: Keyword.t() - ) :: String.t() -end diff --git a/lib/lightning/auth_providers/salesforce.ex b/lib/lightning/auth_providers/salesforce.ex deleted file mode 100644 index aafcd58d54a..00000000000 --- a/lib/lightning/auth_providers/salesforce.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule Lightning.AuthProviders.Salesforce do - @moduledoc """ - Handles the specifics of the Salesforce OAuth authentication process. - """ - @behaviour Lightning.AuthProviders.OAuthBehaviour - - alias Lightning.AuthProviders.Common - require Logger - - def provider_name, do: "Salesforce" - - def scopes, - do: %{ - optional: - ~w(cdp_query_api pardot_api cdp_profile_api chatter_api cdp_ingest_api - eclair_api wave_api api custom_permissions id lightning content openid full visualforce - web chatbot_api user_registration_api forgot_password cdp_api sfap_api interaction_api), - mandatory: ~w(refresh_token) - } - - def scopes_doc_url, - do: - "https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm&type=5" - - def wellknown_url(sandbox) do - key = if sandbox, do: :sandbox_wellknown_url, else: :prod_wellknown_url - config = Lightning.Config.oauth_provider(:salesforce) - config[key] - end - - @impl true - def build_client(wellknown_url, opts \\ []) do - Common.build_client(:salesforce, wellknown_url, opts) - end - - @impl true - def authorize_url(client, state, scopes \\ [], opts \\ []) do - predefined_scopes = ~w[refresh_token] - combined_scopes = Enum.uniq(predefined_scopes ++ scopes) - Common.authorize_url(client, state, combined_scopes, opts) - end - - @impl true - def get_token(client, wellknown_url, params) do - Common.get_token(client, params) - |> Common.introspect(:salesforce, wellknown_url) - end - - @impl true - def refresh_token(client, token, wellknown_url) do - Common.refresh_token(client, token) - |> Common.introspect(:salesforce, wellknown_url) - end - - @impl true - def refresh_token(token, wellknown_url) do - {:ok, %OAuth2.Client{} = client} = build_client(wellknown_url) - refresh_token(client, token, wellknown_url) - end - - @impl true - def get_userinfo(client, token, wellknown_url) do - Common.get_userinfo(client, token, wellknown_url) - end -end diff --git a/lib/lightning/auth_providers/well_known.ex b/lib/lightning/auth_providers/well_known.ex index 347d188056f..1a3e16efc91 100644 --- a/lib/lightning/auth_providers/well_known.ex +++ b/lib/lightning/auth_providers/well_known.ex @@ -4,20 +4,23 @@ defmodule Lightning.AuthProviders.WellKnown do """ use HTTPoison.Base - @fields [ + @discovery_fields [ :authorization_endpoint, :token_endpoint, :userinfo_endpoint, :introspection_endpoint ] - defstruct @fields + # `:user_emails_endpoint` resolves a verified email for providers (e.g. + # GitHub) whose userinfo endpoint doesn't return one. + defstruct @discovery_fields ++ [:user_emails_endpoint] @type t :: %__MODULE__{ authorization_endpoint: String.t(), token_endpoint: String.t(), userinfo_endpoint: String.t(), - introspection_endpoint: String.t() + introspection_endpoint: String.t(), + user_emails_endpoint: String.t() | nil } @spec fetch(discovery_url :: String.t()) :: @@ -42,7 +45,7 @@ defmodule Lightning.AuthProviders.WellKnown do def new(%{} = json_body) do struct!( __MODULE__, - @fields + @discovery_fields |> Enum.map(fn key -> {key, json_body[key |> to_string()]} end) diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex index 65025b56f76..86d167c983c 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -81,12 +81,6 @@ defmodule Lightning.Config do end end - @impl true - def oauth_provider(key) do - Application.get_env(:lightning, :oauth_clients) - |> Keyword.get(key) - end - @impl true def purge_deleted_after_days do Application.get_env(:lightning, :purge_deleted_after_days) @@ -141,6 +135,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) @@ -487,6 +493,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() @@ -503,7 +511,6 @@ defmodule Lightning.Config do @callback metrics_run_queue_metrics_period_seconds() :: integer() @callback metrics_stalled_run_threshold_seconds() :: integer() @callback metrics_unclaimed_run_threshold_seconds() :: integer() - @callback oauth_provider(key :: atom()) :: keyword() | nil @callback promex_metrics_endpoint_authorization_required?() :: boolean() @callback promex_metrics_endpoint_scheme() :: String.t() @callback promex_metrics_endpoint_token() :: String.t() @@ -617,10 +624,6 @@ defmodule Lightning.Config do impl().repo_connection_token_signer() end - def oauth_provider(key) do - impl().oauth_provider(key) - end - def purge_deleted_after_days do impl().purge_deleted_after_days() end @@ -661,6 +664,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 27f3ec2cb07..0966929b00e 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -515,6 +515,32 @@ defmodule Lightning.Config.Bootstrap do cors_origin: env!("CORS_ORIGIN", :string, "*") |> String.split(",") |> List.wrap() + github_client_id = env!("SSO_GITHUB_CLIENT_ID", :string, nil) + github_client_secret = env!("SSO_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!("SSO_GOOGLE_CLIENT_ID", :string, nil) + google_client_secret = env!("SSO_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 """ @@ -1025,6 +1051,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..50ba7005ada 100644 --- a/lib/lightning/extensions/account_hook.ex +++ b/lib/lightning/extensions/account_hook.ex @@ -3,10 +3,25 @@ defmodule Lightning.Extensions.AccountHook do @behaviour Lightning.Extensions.AccountHooking alias Ecto.Changeset + alias Lightning.Accounts alias Lightning.Accounts.User 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,8 @@ defmodule Lightning.Extensions.AccountHook do |> User.changeset(attrs) |> Repo.insert() end + + defp link_identity(%User{} = user, provider, uid) do + Accounts.link_user_identity(user, 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..4d8c8dc4702 100644 --- a/lib/lightning_web/controllers/oidc_controller.ex +++ b/lib/lightning_web/controllers/oidc_controller.ex @@ -2,13 +2,18 @@ defmodule LightningWeb.OidcController do use LightningWeb, :controller alias Lightning.Accounts + alias Lightning.Accounts.User alias Lightning.AuthProviders alias Lightning.AuthProviders.Handler + alias Lightning.SafetyString alias LightningWeb.OauthCredentialHelper alias LightningWeb.UserAuth action_fallback LightningWeb.FallbackController + @oauth_state_session_key :sso_oauth_state + @pending_signup_session_key :sso_pending_signup + plug :fetch_current_user @doc """ @@ -19,8 +24,30 @@ defmodule LightningWeb.OidcController do if conn.assigns.current_user do UserAuth.redirect_if_user_is_authenticated(conn, nil) else - authorize_url = Handler.authorize_url(handler) + {conn, authorize_url} = + authorize_redirect(conn, handler, provider, :login) + + redirect(conn, external: authorize_url) + end + end + end + + @doc """ + Initiates an SSO link flow for an already-authenticated user. The intent, + provider and current user are carried in a session-bound `state` 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, authorize_url} = + authorize_redirect(conn, handler, provider, :link) + redirect(conn, external: authorize_url) + else + redirect(conn, to: Routes.user_session_path(conn, :new)) end end end @@ -29,45 +56,393 @@ defmodule LightningWeb.OidcController do 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 + def new(conn, %{"provider" => provider, "code" => code, "state" => state}) do + case verify_oauth_state(conn, provider, state) do + {:ok, intent, conn} -> + complete_sso_callback(conn, provider, code, intent) + + {:error, conn} -> + conn + |> put_flash(:error, "Authentication failed") + |> redirect(to: Routes.user_session_path(conn, :new)) + end + end + + # An SSO callback that arrives without our `state` (or with the provider's + # error) can't be tied back to the browser that started the flow, so we + # reject it. + def new(conn, %{"provider" => _provider} = params) + when is_map_key(params, "code") or is_map_key(params, "error") do + conn + |> delete_session(@oauth_state_session_key) + |> put_flash(:error, "Authentication failed") + |> redirect(to: Routes.user_session_path(conn, :new)) + end + + def new(conn, %{"state" => state, "code" => code}) do + broadcast_message(state, %{code: code}) + close_browser_window(conn) + end + + def new(conn, %{"error" => error_message, "state" => state}) do + broadcast_message(state, %{error: error_message}) + 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 complete_sso_callback(conn, provider, code, intent) 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, userinfo} <- Handler.get_userinfo(handler, token), + {:ok, uid} <- fetch_uid(userinfo) do + case intent do + :link -> + handle_sso_link(conn, conn.assigns.current_user, provider, uid) + + :login -> + if legacy_provider?(provider), + do: handle_legacy_login(conn, userinfo), + else: handle_sso_login(conn, provider, uid, userinfo) + end + else + {:error, _reason} -> + conn + |> put_flash(:error, "Authentication failed") + |> redirect(to: failure_redirect(conn, intent)) + end + end + # simple account email matching + defp handle_legacy_login(conn, userinfo) do + with {:ok, email} <- get_provider_email(userinfo, conn) do case Accounts.get_user_by_email(email) do + %User{} = user -> + do_log_in(conn, user) + nil -> conn |> put_flash(:error, "Could not find user account") |> redirect(to: Routes.user_session_path(conn, :new)) + end + end + end - %{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" - }) + defp legacy_provider?(provider) do + not is_nil(AuthProviders.get_existing(provider)) + 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 #{AuthProviders.display_name(provider)} account is already linked." + ) + |> redirect(to: ~p"/profile") + + %User{} -> + conn + |> put_flash( + :error, + "This #{AuthProviders.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 #{AuthProviders.display_name(provider)} account." + ) + |> redirect(to: ~p"/profile") + + {:error, :identity_already_linked} -> + conn + |> put_flash( + :error, + "This #{AuthProviders.display_name(provider)} identity is already linked to a different account." + ) + |> redirect(to: ~p"/profile") + + {:error, :provider_already_linked} -> + conn + |> put_flash( + :error, + "You already have a #{AuthProviders.display_name(provider)} account linked. Unlink it first to link a different one." + ) + |> redirect(to: ~p"/profile") + + {:error, _reason} -> + conn + |> put_flash( + :error, + "Could not link your #{AuthProviders.display_name(provider)} account. Please try again." + ) + |> redirect(to: ~p"/profile") + end + end + end + + defp handle_sso_login(conn, provider, uid, userinfo) do + with {:ok, email} <- get_provider_email(userinfo, conn) do + case Accounts.get_user_by_identity(provider, uid) do + %User{} = user -> + do_log_in(conn, user) + + nil -> + if email_verified?(userinfo) do + handle_unlinked_identity(conn, provider, uid, email, userinfo) + else + conn + |> put_flash( + :error, + "Your #{AuthProviders.display_name(provider)} email address has not been verified. Please verify it with #{AuthProviders.display_name(provider)} and try signing in again." + ) + |> redirect(to: Routes.user_session_path(conn, :new)) + end end end end - def new(conn, %{"state" => state, "code" => code}) do - broadcast_message(state, %{code: code}) - close_browser_window(conn) + defp get_provider_email(%{"email" => email}, _conn) when is_binary(email) do + {:ok, email} end - def new(conn, %{"error" => error_message, "state" => state}) do - broadcast_message(state, %{error: error_message}) - close_browser_window(conn) + defp get_provider_email(_userinfo, conn) do + conn + |> put_flash( + :error, + "Could not retrieve your email from the provider. Please ensure your email address is accessible." + ) + |> redirect(to: failure_redirect(conn, :login)) + end + + defp handle_unlinked_identity(conn, provider, uid, email, userinfo) do + case Accounts.get_user_by_email(email) do + %User{} -> + conn + |> put_flash( + :info, + "An account already exists for #{email}. Sign in and link your #{AuthProviders.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 + + 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_uid(userinfo) do + case extract_uid(userinfo) do + nil -> {:error, :no_uid} + uid -> {:ok, uid} + end + end + + defp email_verified?(%{"email_verified" => verified}), + do: verified in [true, "true"] + + defp email_verified?(_), do: false + + 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: ""} + + # Mints a single-use, session-bound state and returns the provider authorize + # URL carrying it. The nonce lives in the session (the secret the attacker + # can't read); the encrypted state carries the same nonce plus the intent, + # provider and — for the link flow — the user it must remain bound to. + defp authorize_redirect(conn, handler, provider, intent) do + nonce = Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false) + + user_id = + case conn.assigns.current_user do + %User{id: id} -> id + _ -> "" + end + + state = + SafetyString.encode([ + nonce, + to_string(intent), + provider, + to_string(user_id) + ]) + + conn = put_session(conn, @oauth_state_session_key, nonce) + + {conn, Handler.authorize_url(handler, state: state)} + end + + # Verifies the callback `state` against the nonce stashed in the session. The + # nonce is consumed (single-use) regardless of outcome. On the link flow the + # state must also still belong to the currently authenticated user. + defp verify_oauth_state(conn, provider, state) do + session_nonce = get_session(conn, @oauth_state_session_key) + conn = delete_session(conn, @oauth_state_session_key) + + with [nonce, intent, state_provider, user_id] <- decode_oauth_state(state), + true <- is_binary(session_nonce), + true <- Plug.Crypto.secure_compare(nonce, session_nonce), + true <- state_provider == provider, + {:ok, intent} <- authorize_intent(conn, intent, user_id) do + {:ok, intent, conn} + else + _ -> {:error, conn} + end + end + + defp decode_oauth_state(state) when is_binary(state) do + SafetyString.decode(state) + rescue + _ -> :error + end + + defp decode_oauth_state(_state), do: :error + + defp authorize_intent(_conn, "login", _user_id), do: {:ok, :login} + + defp authorize_intent(conn, "link", user_id) do + case conn.assigns.current_user do + %User{id: id} -> + if to_string(id) == user_id, do: {:ok, :link}, else: :error + + _ -> + :error + end + end + + defp authorize_intent(_conn, _intent, _user_id), do: :error + + defp failure_redirect(conn, intent) do + if intent == :link && conn.assigns.current_user do + ~p"/profile" + else + Routes.user_session_path(conn, :new) + end end defp broadcast_message(state, data) do 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..19815a0b8a0 --- /dev/null +++ b/lib/lightning_web/controllers/oidc_html/confirm_signup.html.heex @@ -0,0 +1,68 @@ + + + <:header> + + <:title>Create your account + + + +
+ <.form + :let={_f} + for={%{}} + as={:signup} + action={~p"/authenticate/signup/confirm"} + > +
+
+ <%= if error = Phoenix.Flash.get(@flash, :error) do %> + + <% end %> + +

+ No account exists for {@pending["email"]} + yet. We'll create a new account using your + + {Lightning.AuthProviders.display_name(@pending["provider"])} + + profile. +

+ +
+
+
Email
+
{@pending["email"]}
+
+ <%= if @pending["first_name"] != "" or @pending["last_name"] != "" do %> +
+
Name
+
{@pending["first_name"]} {@pending["last_name"]}
+
+ <% end %> +
+
Provider
+
+ {Lightning.AuthProviders.display_name(@pending["provider"])} +
+
+
+ +
+ <.button type="submit" theme="primary"> + Create account and continue + + <.link + href={~p"/authenticate/signup/cancel"} + class="text-xs text-secondary-700 text-center" + > + Cancel + +
+
+
+ +
+
+
diff --git a/lib/lightning_web/controllers/user_registration_controller.ex b/lib/lightning_web/controllers/user_registration_controller.ex index eb5508f4618..eb2d7d98cb5 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, + providers: LightningWeb.UserSessionController.provider_buttons() + ) 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, + providers: LightningWeb.UserSessionController.provider_buttons() + ) 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..28147d03dd2 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,26 @@ <.button type="submit" theme="primary"> Register + <%= if @providers.social != [] do %> +
+ or +
+ <%= for provider <- @providers.social do %> + <.button_link theme="secondary" href={provider.url}> +
+ + + Sign up with {Lightning.AuthProviders.display_name( + 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..99faf9ccf4e 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() + providers: provider_buttons() ) 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() + providers: provider_buttons() ) %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() + providers: provider_buttons() ) %User{mfa_enabled: true} = user -> @@ -47,11 +47,21 @@ defmodule LightningWeb.UserSessionController do |> UserAuth.log_in_user(user) |> UserAuth.redirect_with_return_to(user_params) + {:error, :sso_account} -> + conn + |> put_flash( + :error, + "This account uses single sign-on. Please log in with your SSO provider." + ) + |> render("new.html", + providers: provider_buttons() + ) + _ -> conn |> put_flash(:error, "Invalid email or password") |> render("new.html", - auth_handler_url: auth_handler_url() + providers: provider_buttons() ) end end @@ -76,13 +86,46 @@ defmodule LightningWeb.UserSessionController do |> UserAuth.log_out_user() end - def auth_handler_url do - case Lightning.AuthProviders.get_handlers() do - {:ok, []} -> - nil + @doc """ + Returns the two independent kinds of SSO buttons for the login page: + + * `social` — the built-in GitHub/Google buttons, shown when their `SSO_*` + envs are set (derived straight from the env-based handler builders). + * `external_url` — the generic "via external provider" button, shown when a + provider is configured in the admin portal (an `AuthConfig` row). - {:ok, [handler | _rest]} -> - Lightning.AuthProviders.get_authorize_url(handler) + Each is driven solely by its own source, so one never suppresses the other. + """ + def provider_buttons do + %{ + social: social_providers(), + external_url: external_provider_url() + } + end + + defp social_providers do + [ + Lightning.AuthProviders.GithubHandler, + Lightning.AuthProviders.GoogleHandler + ] + |> Enum.flat_map(fn handler_module -> + case handler_module.build() do + {:ok, handler} -> + [%{name: handler.name, url: ~p"/authenticate/#{handler.name}"}] + + _ -> + [] + end + end) + end + + defp external_provider_url do + case Lightning.AuthProviders.get_existing() do + %Lightning.AuthProviders.AuthConfig{name: name} -> + ~p"/authenticate/#{name}" + + _ -> + nil 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..1e5be9c46f6 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,41 @@ <.button type="submit" theme="primary"> Log in - <%= if @auth_handler_url do %> + <%= if @providers.social != [] or @providers.external_url do %>
or
- <.button theme="secondary"> - -
- <.icon - name="hero-identification" - class="h-4 w-4 inline-block" + <%= for provider <- @providers.social do %> + <.button_link theme="secondary" href={provider.url}> +
+ - - via external provider + + Sign in with {Lightning.AuthProviders.display_name( + provider.name + )}
-
- + + <% end %> + <.button_link + :if={@providers.external_url} + theme="secondary" + class="w-full text-center" + href={@providers.external_url} + > +
+ <.icon + name="hero-identification" + class="h-4 w-4 inline-block" + /> + + via external provider + +
+ <% end %>