Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .actrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Use native arm64 containers on M-series Macs. setup-beam auto-picks
# the right Erlang/OTP prebuilt for the container arch; forcing amd64
# emulation (act's default on arm64 hosts) can leave you with binaries
# built for a different arch than the one actually running.
#
# Pin Ubuntu 20.04: setup-beam's arm64 Erlang/OTP prebuilds on
# builds.hex.pm are ONLY built against Ubuntu 20.04 (libssl1.1) —
# there is no arm64/ubuntu-22.04 variant at the time of writing.
# Using a newer Ubuntu image (22.04/24.04) breaks OTP's :crypto NIF
# with `libcrypto.so.1.1: cannot open shared object file`, which in
# turn breaks `mix local.rebar` (can't make HTTPS requests).
-P ubuntu-20.04=catthehacker/ubuntu:act-20.04
-P ubuntu-22.04=catthehacker/ubuntu:act-20.04
-P ubuntu-24.04=catthehacker/ubuntu:act-20.04
-P ubuntu-latest=catthehacker/ubuntu:act-20.04
42 changes: 26 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
otp-version: '27.3'
elixir-version: '1.18.4'
version-file: .tool-versions
version-type: strict
- name: Cache library deps
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
Expand Down Expand Up @@ -60,15 +60,15 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
otp-version: '27.3'
elixir-version: '1.18.4'
version-file: .tool-versions
version-type: strict
- name: Cache example deps
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
test/example/deps
test/example/_build
key: ${{ runner.os }}-example-${{ hashFiles('test/example/mix.lock') }}
key: ${{ runner.os }}-example-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }}
- name: Fetch example deps
working-directory: test/example
run: mix deps.get
Expand Down Expand Up @@ -108,8 +108,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
otp-version: '27.3'
elixir-version: '1.18.4'
version-file: .tool-versions
version-type: strict
- name: Cache Sigra library deps
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
Expand Down Expand Up @@ -155,15 +155,15 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
otp-version: '27.3'
elixir-version: '1.18.4'
version-file: .tool-versions
version-type: strict
- name: Cache example deps
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
test/example/deps
test/example/_build
key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock') }}
key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }}
- name: Fetch example deps
working-directory: test/example
env:
Expand Down Expand Up @@ -210,8 +210,8 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
otp-version: '27.3'
elixir-version: '1.18.4'
version-file: .tool-versions
version-type: strict
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
node-version: '20'
Expand All @@ -223,7 +223,7 @@ jobs:
path: |
test/example/deps
test/example/_build
key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock') }}
key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }}
- name: Fetch example deps
working-directory: test/example
env:
Expand Down Expand Up @@ -257,15 +257,25 @@ jobs:
PGHOST: localhost
PHX_SERVER: "true"
run: mix phx.server &
- name: Wait for app to respond
- name: Wait for app and warm up LiveView routes
run: |
# Wait for root.
for i in $(seq 1 30); do
if curl -sf http://localhost:4000/ > /dev/null; then
echo "App responding after ${i}s"; exit 0
echo "App responding after ${i}s"
break
fi
sleep 1
done
echo "FAIL: app did not respond within 30s"; exit 1
# Warm up the routes the test will hit. With `plug_init_mode:
# :runtime` in dev, first-touch of each route pays compile-on-
# demand latency — without this, Playwright's first toHaveURL
# check on /users/register races the first LiveView compile.
for path in /users/register /users/log_in /users/confirm /dev/mailbox /users/sessions /users/sudo /users/settings/mfa; do
curl -sf -o /dev/null "http://localhost:4000${path}" \
&& echo "warmed ${path}" \
|| echo "warmup miss ${path} (non-fatal)"
done
- name: Run Playwright golden-path spec
working-directory: test/example/priv/playwright
env:
Expand Down
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erlang 28.0
elixir 1.19.5-otp-28
15 changes: 10 additions & 5 deletions lib/mix/tasks/sigra.install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,12 @@ defmodule Mix.Tasks.Sigra.Install do
# Oban queue injection (per D-22)
inject_oban_queue(otp_app)

# Swoosh config detection (per D-19/D-55)
inject_swoosh_config(otp_app, context_module)
# Swoosh config detection (per D-19/D-55). Adapter must be configured on
# the raw Swoosh.Mailer (`<AppModule>.Mailer`), NOT on the Sigra behaviour
# wrapper (`<ContextModule>.Mailer`), since the wrapper delegates to the
# raw mailer and only the raw mailer calls `Swoosh.Mailer.deliver/2`.
app_module = binding[:app_module]
inject_swoosh_config(otp_app, app_module)
end

defp inject_api_files(binding) do
Expand Down Expand Up @@ -681,7 +685,7 @@ defmodule Mix.Tasks.Sigra.Install do
end
end

defp inject_swoosh_config(otp_app, context_module) do
defp inject_swoosh_config(otp_app, app_module) do
dev_config = Path.join(["config", "dev.exs"])

if File.exists?(dev_config) do
Expand All @@ -692,8 +696,9 @@ defmodule Mix.Tasks.Sigra.Install do
else
swoosh_block = """

# Sigra email delivery (dev)
config :#{otp_app}, #{context_module}.Mailer,
# Sigra email delivery (dev) — adapter is set on the raw Swoosh.Mailer
# module, not the Sigra.Mailer behaviour wrapper.
config :#{otp_app}, #{app_module}.Mailer,
adapter: Swoosh.Adapters.Local

config :swoosh, :api_client, false
Expand Down
9 changes: 8 additions & 1 deletion lib/sigra/delivery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ defmodule Sigra.Delivery do

defp delivery_mode(opts) do
case Keyword.get(opts, :delivery_mode, :auto) do
:auto -> if Code.ensure_loaded?(Oban), do: :async, else: :sync
:auto -> if oban_running?(), do: :async, else: :sync
mode -> mode
end
end

# :auto must only route to :async when Oban is actually supervised in the
# host app — not merely compiled/loadable. Apps that add `{:oban, ...}` to
# mix.exs without wiring the supervisor would otherwise crash on insert.
defp oban_running? do
Code.ensure_loaded?(Oban) and Process.whereis(Oban) != nil
end
end
26 changes: 20 additions & 6 deletions lib/sigra/mfa.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,18 @@ defmodule Sigra.MFA do

codes = BackupCodes.generate(backup_count)

# Backup codes are effectively write-once — only `used_at` ever
# changes when a code is consumed. The shipped schemas use
# `timestamps(updated_at: false)`, so we only populate inserted_at.
# Any consumer schema that DOES have updated_at will get it
# auto-populated via its own changeset path, not this bulk insert.
entries =
Enum.map(codes, fn {_formatted, hashed} ->
%{
user_id: user.id,
hashed_code: hashed,
used_at: nil,
inserted_at: now,
updated_at: now
inserted_at: now
}
end)

Expand Down Expand Up @@ -549,10 +553,20 @@ defmodule Sigra.MFA do

"""
@doc since: "0.6.0"
@spec status(Sigra.Config.t(), struct()) :: map()
def status(%Sigra.Config{} = config, user) do
mfa_credential_schema = Keyword.get(config.mfa, :mfa_credential_schema)
backup_code_schema = Keyword.get(config.mfa, :backup_code_schema)
@spec status(Sigra.Config.t(), struct(), keyword()) :: map()
def status(%Sigra.Config{} = config, user, opts \\ []) do
# The `config.mfa` keyword list is validated by NimbleOptions and does
# NOT accept `:mfa_credential_schema` or `:backup_code_schema` — those
# are per-call opts, the same pattern used by confirm_enrollment/3,
# verify/4, and disable/4. Fall back to config.mfa so callers that
# previously used an un-validated config still work.
mfa_credential_schema =
Keyword.get(opts, :mfa_credential_schema) ||
Keyword.get(config.mfa || [], :mfa_credential_schema)

backup_code_schema =
Keyword.get(opts, :backup_code_schema) ||
Keyword.get(config.mfa || [], :backup_code_schema)

if mfa_credential_schema do
case config.repo.get_by(mfa_credential_schema, user_id: user.id) do
Expand Down
2 changes: 2 additions & 0 deletions lib/sigra/workers/account_deletion.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
if Code.ensure_loaded?(Oban.Worker) do
defmodule Sigra.Workers.AccountDeletion do
@moduledoc """
Oban worker for executing scheduled account deletions.
Expand Down Expand Up @@ -80,3 +81,4 @@ defmodule Sigra.Workers.AccountDeletion do
from(t in "user_tokens", where: t.user_id == ^user.id)
end
end
end
2 changes: 2 additions & 0 deletions lib/sigra/workers/audit_cleanup.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
if Code.ensure_loaded?(Oban.Worker) do
defmodule Sigra.Workers.AuditCleanup do
@moduledoc """
Optional Oban worker that deletes audit rows older than the configured
Expand Down Expand Up @@ -53,3 +54,4 @@ defmodule Sigra.Workers.AuditCleanup do
Sigra.Audit.do_cleanup(repo, audit_schema, retention_days)
end
end
end
2 changes: 2 additions & 0 deletions lib/sigra/workers/email_delivery.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
if Code.ensure_loaded?(Oban.Worker) do
defmodule Sigra.Workers.EmailDelivery do
@moduledoc """
Oban worker for asynchronous email delivery.
Expand Down Expand Up @@ -99,3 +100,4 @@ defmodule Sigra.Workers.EmailDelivery do
end
end
end
end
2 changes: 2 additions & 0 deletions lib/sigra/workers/token_cleanup.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
if Code.ensure_loaded?(Oban.Worker) do
defmodule Sigra.Workers.TokenCleanup do
@moduledoc """
Oban cron worker for cleaning up expired tokens.
Expand Down Expand Up @@ -219,3 +220,4 @@ defmodule Sigra.Workers.TokenCleanup do
defp get_repo(%{"repo" => repo_string}), do: String.to_existing_atom(repo_string)
defp get_token_schema(%{"token_schema" => schema_string}), do: String.to_existing_atom(schema_string)
end
end
33 changes: 33 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Sigra.MixProject do
version: @version,
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
elixirc_options: elixirc_options(),
start_permanent: Mix.env() == :prod,
deps: deps(),
# test_load_filters: tells `mix test` which files to load via require.
Expand Down Expand Up @@ -40,6 +41,38 @@ defmodule Sigra.MixProject do
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

# Silence undefined-function warnings for optional deps and for internal
# modules that are conditionally compiled behind optional-dep guards. When
# Sigra is pulled in by a consumer that doesn't add these deps to its own
# mix.exs, the compiler would otherwise warn on every reference and break
# `mix compile --warnings-as-errors` downstream.
defp elixirc_options do
[
no_warn_undefined: [
# Optional deps (mix.exs: optional: true)
Bcrypt,
Hammer,
Swoosh.Email,
Oban,
Oban.Worker,
Oban.Job,
Assent.Strategy.Apple,
Assent.Strategy.Facebook,
Assent.Strategy.Github,
Assent.Strategy.Google,
Joken,
Joken.Signer,
Joken.Config,
EQRCode,
# Internal modules defined only when an optional dep is loaded
Sigra.Workers.AccountDeletion,
Sigra.Workers.AuditCleanup,
Sigra.Workers.EmailDelivery,
Sigra.Workers.TokenCleanup
]
]
end

defp deps do
[
{:phoenix, "~> 1.8"},
Expand Down
32 changes: 28 additions & 4 deletions priv/templates/sigra.install/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,21 @@ defmodule <%= context_module %> do
hashed token in Sigra's canonical `user_sessions` store.
"""
def get_user_by_session_token(raw_token) when is_binary(raw_token) do
case get_user_and_session_by_token(raw_token) do
{user, _session} -> user
nil -> nil
end
end

def get_user_by_session_token(_), do: nil

@doc """
Looks up both the user and the session record by raw session cookie
token. Returns `{user, session}` on success or `nil` on failure. Used
by code paths that need the session record itself — e.g. the sudo
controller needs `session.hashed_token` to mark sudo confirmation.
"""
def get_user_and_session_by_token(raw_token) when is_binary(raw_token) do
with {:ok, raw_bytes} <- Base.url_decode64(raw_token, padding: false) do
hashed = Sigra.Token.hash_token(raw_bytes)
config = sigra_config()
Expand All @@ -273,15 +288,21 @@ defmodule <%= context_module %> do
]

case store.fetch(hashed, store_opts) do
{:ok, session} -> Repo.get(<%= schema_alias %>, session.user_id)
{:error, :not_found} -> nil
{:ok, session} ->
case Repo.get(<%= schema_alias %>, session.user_id) do
nil -> nil
user -> {user, session}
end

{:error, :not_found} ->
nil
end
else
_ -> nil
end
end

def get_user_by_session_token(_), do: nil
def get_user_and_session_by_token(_), do: nil

@doc """
Deletes the session identified by the given raw token from
Expand Down Expand Up @@ -611,7 +632,10 @@ defmodule <%= context_module %> do

@doc "Get MFA status for a user (enrollment state, backup code count, etc.)."
def mfa_status(user) do
Sigra.MFA.status(sigra_config(), user)
Sigra.MFA.status(sigra_config(), user,
mfa_credential_schema: <%= context_module %>.UserMFACredential,
backup_code_schema: <%= context_module %>.UserBackupCode
)
end

## Account Lifecycle
Expand Down
2 changes: 1 addition & 1 deletion priv/templates/sigra.install/reactivation_live.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule <%= web_module %>.Auth.ReactivationLive do
defmodule <%= web_module %>.ReactivationLive do
@moduledoc """
LiveView for account reactivation during the deletion grace period.

Expand Down
2 changes: 1 addition & 1 deletion priv/templates/sigra.install/settings_live.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule <%= web_module %>.Auth.SettingsLive do
defmodule <%= web_module %>.SettingsLive do
@moduledoc """
LiveView for account settings management.

Expand Down
Loading
Loading