Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
122 changes: 121 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,126 @@ defmodule Lightning.Accounts do
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user

cond do
is_nil(user) ->
User.valid_password?(user, password)
nil

is_nil(user.hashed_password) ->
User.valid_password?(user, password)
{:error, :sso_account}

User.valid_password?(user, password) ->
user

true ->
nil
end
end

@doc """
Looks up a user by their SSO provider identity.

Returns the `%User{}` if found, otherwise `nil`.
"""
def get_user_by_identity(provider, uid) do
from(u in User,
join: i in UserIdentity,
on: i.user_id == u.id,
where: i.provider == ^provider and i.uid == ^uid
)
|> Repo.one()
end

@doc """
Links an SSO provider identity to an existing user account.

Silently succeeds if the identity already exists (on_conflict: :nothing).
"""
def link_user_identity(%User{id: user_id}, provider, uid) do
%UserIdentity{}
|> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid})
|> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid])
end

@doc """
Returns the SSO identities linked to a user, ordered by provider name.
"""
def list_user_identities(%User{id: user_id}) do
from(i in UserIdentity,
where: i.user_id == ^user_id,
order_by: [asc: i.provider]
)
|> Repo.all()
end

@doc """
Gets a user's identity for a given provider, or `nil` if not linked.
"""
def get_user_identity(%User{id: user_id}, provider) do
Repo.get_by(UserIdentity, user_id: user_id, provider: provider)
end

@doc """
Removes the SSO identity for the given user and provider.

Refuses to remove the last identity for an SSO-only user (no password set),
since that would lock them out. Such users can set a password by going
through the password reset flow first.

Returns:
* `{:ok, identity}` when the identity is removed
* `{:error, :not_linked}` when the user has no identity for the provider
* `{:error, :would_lock_out}` when removing would leave an SSO-only user
with no way to log in
"""
def unlink_user_identity(%User{} = user, provider) do
case get_user_identity(user, provider) do
nil ->
{:error, :not_linked}

%UserIdentity{} = identity ->
if can_remove_identity?(user, identity) do
Repo.delete(identity)
else
{:error, :would_lock_out}
end
end
end

defp can_remove_identity?(%User{hashed_password: hp}, _identity)
when is_binary(hp),
do: true

defp can_remove_identity?(%User{} = user, %UserIdentity{id: identity_id}) do
other_count =
from(i in UserIdentity,
where: i.user_id == ^user.id and i.id != ^identity_id,
select: count(i.id)
)
|> Repo.one()

other_count > 0
end

@doc """
Registers a brand-new user via SSO.

The user is created without a password and confirmed immediately.
A `user_registered` event is broadcast on success.
"""
def register_user_from_sso(attrs, provider, uid) do
attrs = Map.put(attrs, :sso_identity, %{provider: provider, uid: uid})

Repo.transact(fn ->
AccountHook.handle_register_user(attrs)
end)
|> tap(fn result ->
with {:ok, user} <- result do
Events.user_registered(user)
end
end)
end

@doc """
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
42 changes: 36 additions & 6 deletions lib/lightning/auth_providers/cache_warmer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ defmodule Lightning.AuthProviders.CacheWarmer do
# https://github.com/whitfin/cachex/issues/276
@dialyzer {:nowarn_function, init: 1}

# `GithubHandler.build/0` and `GoogleHandler.build/0` read their client
# credentials through `Lightning.Config.*_oauth/1`, which dispatches
# dynamically through an extension module. Dialyzer can't see that the
# binary branch is reachable, so it concludes `build/0` only ever returns
# `{:error, :not_configured}` and flags the `{:ok, _}` pattern here as
# unreachable. The runtime behaviour is fine.
@dialyzer {:nowarn_function, execute: 1}

@doc """
Returns the interval for this warmer.
"""
Expand All @@ -20,12 +28,34 @@ defmodule Lightning.AuthProviders.CacheWarmer do
Executes this cache warmer with a connection.
"""
def execute(_state) do
with %AuthProviders.AuthConfig{name: name} = config <-
AuthProviders.get_existing() || :ignore,
{:ok, handler} <- AuthProviders.Handler.from_model(config) do
{:ok, [{name, handler}]}
else
_error -> :ignore
db_entries =
try do
with %AuthProviders.AuthConfig{name: name} = config <-
AuthProviders.get_existing() || :not_found,
{:ok, handler} <- AuthProviders.Handler.from_model(config) do
[{name, handler}]
else
_ -> []
end
rescue
_ -> []
end

github_entries =
case Lightning.AuthProviders.GithubHandler.build() do
{:ok, handler} -> [{handler.name, handler}]
_ -> []
end

google_entries =
case Lightning.AuthProviders.GoogleHandler.build() do
{:ok, handler} -> [{handler.name, handler}]
_ -> []
end

case db_entries ++ github_entries ++ google_entries do
[] -> :ignore
entries -> {:ok, entries}
end
end
end
42 changes: 42 additions & 0 deletions lib/lightning/auth_providers/github_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Lightning.AuthProviders.GithubHandler do
@moduledoc """
Builds a Handler for GitHub OAuth2 SSO login from environment configuration.

Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to enable GitHub login.
"""

alias Lightning.AuthProviders.Handler
alias Lightning.AuthProviders.WellKnown

@name "github"
@authorization_endpoint "https://github.com/login/oauth/authorize"
@token_endpoint "https://github.com/login/oauth/access_token"
@userinfo_endpoint "https://api.github.com/user"

def handler_name, do: @name

@spec build() :: {:ok, Handler.t()} | {:error, :not_configured}
def build do
client_id = Lightning.Config.github_oauth(:client_id)
client_secret = Lightning.Config.github_oauth(:client_secret)
redirect_uri = Lightning.Config.github_oauth(:redirect_uri)

if client_id && client_secret && redirect_uri do
wellknown = %WellKnown{
authorization_endpoint: @authorization_endpoint,
token_endpoint: @token_endpoint,
userinfo_endpoint: @userinfo_endpoint
}

Handler.new(@name,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
wellknown: wellknown,
scope: "read:user user:email"
)
else
{:error, :not_configured}
end
end
end
42 changes: 42 additions & 0 deletions lib/lightning/auth_providers/google_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Lightning.AuthProviders.GoogleHandler do
@moduledoc """
Builds a Handler for Google OAuth2 SSO login from environment configuration.

Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to enable Google login.
"""

alias Lightning.AuthProviders.Handler
alias Lightning.AuthProviders.WellKnown

@name "google"
@authorization_endpoint "https://accounts.google.com/o/oauth2/v2/auth"
@token_endpoint "https://oauth2.googleapis.com/token"
@userinfo_endpoint "https://openidconnect.googleapis.com/v1/userinfo"

def handler_name, do: @name

@spec build() :: {:ok, Handler.t()} | {:error, :not_configured}
def build do
client_id = Lightning.Config.google_oauth(:client_id)
client_secret = Lightning.Config.google_oauth(:client_secret)
redirect_uri = Lightning.Config.google_oauth(:redirect_uri)

if client_id && client_secret && redirect_uri do
wellknown = %WellKnown{
authorization_endpoint: @authorization_endpoint,
token_endpoint: @token_endpoint,
userinfo_endpoint: @userinfo_endpoint
}

Handler.new(@name,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
wellknown: wellknown,
scope: "openid email profile"
)
else
{:error, :not_configured}
end
end
end
Loading
Loading