Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ and this project adheres to

### Added

- Single Sign-On (SSO) sign-in with GitHub and Google, including account
[#4621](https://github.com/OpenFn/lightning/issues/4621)

### Changed

### Fixed
Expand Down
43 changes: 43 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,51 @@ been marked as recovered.
Once all files have either been recovered or discarded, the triggers can be
enabled once more.

### 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 | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | `https://<ENDPOINT DOMAIN>/authenticate/github/callback` |
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | `https://<ENDPOINT DOMAIN>/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.

> ⚠️ **Upgrading from the older Google login?** The new Google SSO provider
> reuses the same `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` as the
> [Google Oauth2](#google-oauth2) setup below, but expects the callback
> `/authenticate/google/callback` (not `/authenticate/callback`). Add the new
> callback URL to your Google OAuth client's authorized redirect URIs.

### Google Oauth2

> These `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` variables are also used by
> [Single Sign-On (SSO)](#single-sign-on-sso) above, which expects a different
> callback URL (`/authenticate/google/callback`). If you use Google for SSO
> sign-in, register both callback URLs on the same OAuth client.

Using your Google Cloud account, provision a new OAuth 2.0 Client with the 'Web
application' type.

Expand Down
137 changes: 136 additions & 1 deletion lib/lightning/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -172,7 +173,141 @@ 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, `{:error, :identity_already_linked}` if claimed by another.
"""
def link_user_identity(%User{id: user_id}, provider, uid) do
case get_identity(provider, uid) do
%UserIdentity{user_id: ^user_id} = identity ->
{:ok, identity}

%UserIdentity{} ->
{:error, :identity_already_linked}

nil ->
%UserIdentity{}
|> UserIdentity.changeset(%{
user_id: user_id,
provider: provider,
uid: uid
})
|> Repo.insert()
end
end

defp get_identity(provider, uid) do
Repo.get_by(UserIdentity, provider: provider, uid: 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 """
Expand Down
18 changes: 18 additions & 0 deletions lib/lightning/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions lib/lightning/accounts/user_identity.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/lightning/auth_providers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ 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()
def display_name(provider), do: String.capitalize(provider)

@spec get_existing() :: AuthConfig.t() | nil
def get_existing do
from(ap in AuthConfig) |> Repo.one()
Expand Down
50 changes: 44 additions & 6 deletions lib/lightning/auth_providers/cache_warmer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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
Loading