Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8a494b6
feat: support github sso
doc-han May 14, 2026
5c4de29
feat: sso register
doc-han May 15, 2026
6c61868
feat: support linking & unlinking for identities
doc-han May 19, 2026
671b321
feat: add new provider
doc-han May 19, 2026
d9fa75f
feat: disable auto-link of acocunts
doc-han May 22, 2026
448d93c
feat: prompt user before creating new sso account
doc-han May 22, 2026
a1cbc2a
feat: route so registration through account-hook
doc-han May 22, 2026
aaf20a9
tests: update tests
doc-han May 25, 2026
d83242a
chore: resolve dialyzer
doc-han May 25, 2026
6883427
Merge branch 'main' into 4621-full-sso-experience
doc-han May 25, 2026
fc630ad
feat: pick user email from dedicated endpoint (Github)
doc-han Jun 5, 2026
4e579f9
chore: update deployment.md
doc-han Jun 5, 2026
3c04662
feat: rollback
doc-han Jun 5, 2026
1985ee1
feat: only allow verified emails
doc-han Jun 5, 2026
f4bcd1a
feat: enforce at most one identity match
doc-han Jun 5, 2026
a57c320
chore: break long lines
doc-han Jun 5, 2026
e85908b
refactor: reuse methods
doc-han Jun 5, 2026
213850d
chore: update changelog
doc-han Jun 5, 2026
99f5e86
refactor: remove unused modules (#4825)
doc-han Jun 8, 2026
3667a84
feat: resolve csrf issue
doc-han Jun 16, 2026
87aa4a2
feat: prevent multiple identities per provider
doc-han Jun 16, 2026
2d43a16
fix: correct vertical alignment of SSO provider buttons
lmac-1 Jun 17, 2026
ba4c921
fix: use correct capitalisation for SSO provider display names
lmac-1 Jun 17, 2026
3cdca67
test: update Github assertion to GitHub
lmac-1 Jun 17, 2026
8a00180
refactor: remove redundant Google clause from display_name/1
lmac-1 Jun 17, 2026
4766975
feat: add sso_ prefix to envs & remove salesforce docs
doc-han Jun 22, 2026
b9cb25c
feat: implement unlink locking
doc-han Jun 22, 2026
5577b32
feat: sso users should set password from profile screen
doc-han Jun 23, 2026
2929b39
feat: email change allowed when password is set
doc-han Jun 23, 2026
8de9755
feat: webhook auth view methods
doc-han Jun 23, 2026
3a8c6e7
fix: don't require provider email when linking SSO identity
midigofrank Jun 24, 2026
179b51c
fix: handle userinfo fetch failures without raising
midigofrank Jun 24, 2026
09b0f17
fix: derive SSO email_verified from the provider emails endpoint
midigofrank Jun 24, 2026
caa8fc7
fix: report unlink delete failures distinctly from not-linked
midigofrank Jun 24, 2026
ec7867a
refactor: use Repo.exists? for the unlink lock-out check
midigofrank Jun 24, 2026
c5f9be4
feat: show legacy auth button
doc-han Jun 24, 2026
4de683d
Merge branch 'main' into 4621-full-sso-experience
doc-han Jun 24, 2026
b980672
fix: email validation
doc-han Jun 24, 2026
4c9b0aa
feat: link/unlink new sso providers only
doc-han Jun 25, 2026
188ca26
Merge branch 'main' into 4621-full-sso-experience
doc-han Jun 26, 2026
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