Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 6 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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. 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
Expand Down
87 changes: 55 additions & 32 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ENDPOINT DOMAIN>/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://<ENDPOINT DOMAIN>/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://<ENDPOINT DOMAIN>/authenticate/github/callback` |
| Google | `SSO_GOOGLE_CLIENT_ID`, `SSO_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.

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

Expand Down
195 changes: 193 additions & 2 deletions 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,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 """
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 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 @@ -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)
Expand Down Expand Up @@ -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.
"""
Expand Down
Loading