Core authentication orchestrator.
All security-critical auth operations live here. The generated
MyApp.Auth context delegates to these functions for registration,
authentication, session management, and magic link flows.
Usage
Sigra.Auth.register(MyApp.Repo, attrs, changeset_fn: &MyApp.User.registration_changeset/1)
Sigra.Auth.authenticate(MyApp.Repo, params, user_schema: MyApp.User)Security Properties
- User enumeration prevention: generic error messages, constant-time operations
- Hash upgrade: transparent bcrypt-to-Argon2id migration on successful login
- Failed attempt tracking: incremented on wrong password, reset on success
- Magic link: single-use, 10-minute TTL, rate limited
- Telemetry: all operations emit structured events
Summary
Functions
Authenticates a user by email and password.
Cancel scheduled account deletion.
Cancel a pending email change.
Change password with current password verification.
Completes MFA verification by rotating the session token and upgrading the session type from :mfa_pending to :standard or :remember_me.
Confirm an email change via token.
Confirm sudo mode by updating sudo_at timestamp.
Confirms a user by HMAC-signed link token.
Creates an API token for the user.
Create a new session for the user with connection metadata.
Delete all sessions for a user. Broadcasts PubSub disconnect per D-16.
Delete a specific session by its hashed token.
Execute account deletion (called by Oban worker).
Generates a confirmation link token AND a 6-digit code for the given user.
Generates JWT access + refresh tokens for a user.
Links an OAuth provider to an existing authenticated user.
Returns all registered API token scopes.
Lists active API tokens for a user with cursor pagination.
List all active sessions for a user.
Logs in an existing user via OAuth identity match.
Normalizes an email address for storage and lookup.
Refreshes JWT tokens using a refresh token.
Registers a user.
Registers a new user via OAuth provider callback data.
Pure Ecto.Multi builder for user registration.
Request an email change for the user.
Requests a magic link for the given email.
Requests a password reset for the given email.
Resets a user's password using a valid reset token.
Revokes all active API tokens for a user.
Revokes an API token by ID.
Revokes a JWT refresh token.
Revoke a specific session by hashed_token.
Schedule account deletion with grace period.
Set password for OAuth-only user.
Unlinks an OAuth provider from a user.
Returns true if the given string looks like a valid email address.
Confirms a user by 6-digit code.
Verifies a magic link token. Confirms user if unconfirmed.
Functions
@spec authenticate(module() | Sigra.Config.t(), map(), keyword()) :: {:ok, struct()} | {:ok, struct(), map()} | {:error, :invalid_credentials | :unconfirmed | :account_locked}
Authenticates a user by email and password.
Normalizes the email, looks up the user, verifies the password with automatic hash upgrade support (bcrypt -> Argon2id), and tracks failed login attempts.
Returns {:ok, user} on success with failed attempts reset to 0,
{:error, :invalid_credentials} on failure, or {:error, :unconfirmed}
when confirmation is required and the user has not confirmed their email.
Options
:user_schema- Required. The Ecto schema module for users.:require_confirmation- Whether to checkconfirmed_at. Default:false.
Telemetry
[:sigra, :auth, :login, :start | :stop | :exception]span[:sigra, :auth, :hash_upgraded]event when hash is upgraded
@spec cancel_deletion(Sigra.Config.t(), struct(), keyword()) :: {:ok, struct()} | {:error, term()}
Cancel scheduled account deletion.
Clears deletion timestamps and reactivates the account. The user must re-authenticate (all sessions were revoked on scheduling).
@spec cancel_email_change(Sigra.Config.t(), struct(), keyword()) :: {:ok, struct()} | {:error, term()}
Cancel a pending email change.
Clears the pending_email field and deletes the email change token.
@spec change_password(Sigra.Config.t(), struct(), String.t(), map(), keyword()) :: {:ok, struct()} | {:error, term()}
Change password with current password verification.
Verifies the current password, updates to the new password, and invalidates all sessions except the current one.
@spec complete_mfa_verification( Sigra.Config.t(), struct(), Sigra.Session.t(), keyword() ) :: {:ok, map()} | {:error, term()}
Completes MFA verification by rotating the session token and upgrading the session type from :mfa_pending to :standard or :remember_me.
Called after successful TOTP or backup code verification. The old mfa_pending session token is invalidated and a new token is issued, preventing session fixation attacks.
Options
:remember_me- Iftrue, upgrades to:remember_mesession. Default:false.:trust_browser- Iftrue, includes trust_browser flag in result for "trust this browser" cookie. Default:false.
Returns
{:ok, %{session: session, trust_browser: boolean}}on success{:error, reason}if session creation fails
@spec confirm_email_change(Sigra.Config.t(), String.t(), keyword()) :: {:ok, struct()} | {:error, term()}
Confirm an email change via token.
Verifies the HMAC-signed token, updates the user's email, and invalidates all existing sessions (forcing re-authentication).
@spec confirm_sudo(Sigra.Config.t(), binary(), keyword()) :: :ok | {:error, :not_found}
Confirm sudo mode by updating sudo_at timestamp.
Emits a [:sigra, :session, :sudo] telemetry span.
@spec confirm_user(module(), String.t(), keyword()) :: {:ok, struct()} | {:error, :token_expired | :token_invalid | :already_confirmed}
Confirms a user by HMAC-signed link token.
Verifies the HMAC signature, decodes the raw token, hashes it, looks up in DB. On success, sets confirmed_at and deletes all confirm/confirm_code tokens for the user in a single transaction.
Returns {:ok, user}, {:error, :token_expired}, {:error, :token_invalid},
or {:error, :already_confirmed}.
Options
:secret_key_base- Required. The host app's secret key base.:user_token_schema- Required. The Ecto schema module for user tokens.:user_schema- Required. The Ecto schema module for users.:confirmation_ttl- Token TTL in seconds. Default:172800(48 hours).
@spec create_api_token(Sigra.Config.t(), struct(), map()) :: {:ok, String.t(), struct()} | {:error, term()}
Creates an API token for the user.
Returns {:ok, raw_key, token} on success. Sends a notification email
on successful creation if the email module is configured (D-62).
Parameters
config- A%Sigra.Config{}structuser- The user struct (must have an:idfield)attrs- A map with:name,:scopes, and optional:expires_at
@spec create_session(Sigra.Config.t(), struct(), map(), keyword()) :: {:ok, Sigra.Session.t()} | {:error, term()}
Create a new session for the user with connection metadata.
Creates a session via the configured SessionStore and emits a
[:sigra, :session, :create] telemetry span.
Phase 14: organization selector (D-12, D-26, ORG-SCOPE-06)
When config.organizations_module is set, the function runs the
Sigra.Organizations.select_active_organization/3 selector once per
login and writes the result (or nil) into the newly-created session
row via SessionStore.update_active_organization/3. The selector
call is wrapped in a try/rescue block and MUST NOT fail the login
— selector failures fall back to active_organization_id: nil and
the user sees the picker on their next RequireMembership hit
(T-14-13 mitigation).
Options
:session_store- Override the session store from config.:previous_active_organization_id- Resume pointer passed toSigra.Organizations.select_active_organization/3. When a user with 2+ memberships logs in, the selector uses this to pick up where the user left off (D-12).
@spec delete_all_sessions(Sigra.Config.t(), term(), keyword()) :: {non_neg_integer(), nil}
Delete all sessions for a user. Broadcasts PubSub disconnect per D-16.
Returns {count, nil} where count is the number of deleted sessions.
Options
:except_token- Hashed token to exclude (current session).:pubsub- Phoenix.PubSub module name for LiveView disconnect broadcasts.
@spec delete_session(Sigra.Config.t(), binary(), keyword()) :: :ok
Delete a specific session by its hashed token.
Emits a [:sigra, :session, :delete] telemetry span.
@spec execute_deletion(Sigra.Config.t(), struct(), keyword()) :: {:ok, atom()} | {:error, term()}
Execute account deletion (called by Oban worker).
Applies the configured deletion strategy (soft_delete, hard_delete, or anonymize) to finalize the account removal.
@spec generate_confirmation_token(module(), struct(), keyword()) :: {String.t(), String.t(), struct(), struct()}
Generates a confirmation link token AND a 6-digit code for the given user.
Returns {encoded_token, code, link_token_struct, code_token_struct}.
The encoded_token is HMAC-signed for URL use. The code is a random 6-digit
numeric string. Both are stored as SHA-256 hashes in the DB.
Per D-01: link-first, code as fallback. Both generated together.
Options
:secret_key_base- Required. The host app's secret key base.:user_token_schema- Required. The Ecto schema module for user tokens.
@spec generate_jwt_tokens(Sigra.Config.t(), struct(), [String.t()]) :: {:ok, map()} | {:error, term()}
Generates JWT access + refresh tokens for a user.
@spec link_provider(Sigra.Config.t() | map(), map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
Links an OAuth provider to an existing authenticated user.
Requires sudo mode active. Creates identity record, sends notification email.
Returns {:ok, identity} or {:error, reason}.
@spec list_api_scopes(Sigra.Config.t()) :: [String.t()]
Returns all registered API token scopes.
@spec list_api_tokens(Sigra.Config.t(), term(), keyword()) :: {[struct()], String.t() | nil}
Lists active API tokens for a user with cursor pagination.
@spec list_sessions(Sigra.Config.t(), term(), keyword()) :: [Sigra.Session.t()]
List all active sessions for a user.
Excludes :mfa_pending sessions from the listing since they represent
incomplete authentication attempts, not active sessions (D-29).
@spec login_oauth(Sigra.Config.t() | map(), atom(), map(), map()) :: {:ok, :logged_in, map(), map()} | {:link_confirmation_required, map()} | {:error, term()}
Logs in an existing user via OAuth identity match.
Looks up identity by (provider, provider_uid), updates identity fields, creates session with auth_method: :oauth metadata.
Delegates to Sigra.OAuth.Callback.process_callback/4.
Normalizes an email address for storage and lookup.
Applies String.trim/1 (leading/trailing whitespace) then
String.downcase/1. Sigra stores emails in a citext column so
case-insensitive matching is already enforced at the database level, but
callers doing in-memory comparisons (login form pre-flight, fixture
setup, audit queries) should normalize first.
Returns a string on any binary input; passes through nil untouched so
callers can pipe through without nil-guarding.
Scope (RFC 5321 §2.3.4 caveat)
This function does NOT strip interior whitespace from the local-part.
Quoted local-parts with embedded whitespace (extremely rare, technically
permitted by RFC 5321 §2.3.4) pass through unchanged. Callers MUST also
run valid_email?/1 — which rejects interior whitespace via regex —
before persisting. Passing an unvalidated normalized value straight to a
citext column is a caller error.
Examples
iex> Sigra.Auth.normalize_email("Alice@Example.COM")
"alice@example.com"
iex> Sigra.Auth.normalize_email(" bob@example.com ")
"bob@example.com"
iex> Sigra.Auth.normalize_email("")
""
iex> Sigra.Auth.normalize_email(nil)
nil
# Interior whitespace is retained — run valid_email?/1 to reject.
iex> Sigra.Auth.normalize_email("Alice @example.com")
"alice @example.com"
iex> Sigra.Auth.valid_email?("alice @example.com")
false
@spec refresh_jwt(Sigra.Config.t(), String.t()) :: {:ok, map()} | {:error, :invalid_token | :token_expired | :reuse_detected | :jwt_refresh_aborted}
Refreshes JWT tokens using a refresh token.
When :audit_schema is set, persistence and JWT audit share one
transaction; failures there return {:error, :jwt_refresh_aborted} (see
Sigra.JWT.refresh/3).
@spec register(module(), map(), keyword()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()} | {:error, :email_taken}
Registers a user.
Takes a repo module, attributes map, and options. The :changeset_fn
option must be a function that takes attrs and returns an Ecto.Changeset.
Returns {:ok, user} on success, {:error, changeset} for validation
errors, or {:error, :email_taken} when the email uniqueness constraint
is violated (enumeration-safe -- callers should show a generic message).
Options
:changeset_fn- Required. Function(attrs -> Ecto.Changeset.t()).
Telemetry
Emits [:sigra, :auth, :register, :start | :stop | :exception] span.
On success, metadata includes %{user_id: id}.
@spec register_oauth(Sigra.Config.t() | map(), atom(), map(), map()) :: {:ok, :registered, map(), map()} | {:error, term()}
Registers a new user via OAuth provider callback data.
Creates user + identity in a transaction. Sets confirmed_at if provider email is trusted. Creates a session with auth_method: :oauth metadata.
Delegates to Sigra.OAuth.Callback.process_callback/4 for the full
account routing logic (register/login/link-confirm).
@spec register_user_multi( map(), keyword() ) :: Ecto.Multi.t()
Pure Ecto.Multi builder for user registration.
Returns a multi with a :user step that inserts the user via the
configured :changeset_fn. When :audit_schema is set (see
audit_opts_from_keyword/1), appends auth.register.success via
Audit.log_multi_safe/3 in the same Multi. Makes ZERO Repo calls during
construction — composable via Ecto.Multi.append/2. Intended for use by
Sigra.Organizations.Invitations.accept_with_signup/3 (Phase 17 D-07)
to atomically compose signup + confirm + membership + accept.
Options
:changeset_fn— REQUIRED. 1-arity function producing a user changeset fromattrs.
Example
Ecto.Multi.new()
|> Ecto.Multi.append(
Sigra.Auth.register_user_multi(attrs, changeset_fn: &User.registration_changeset/1)
)
|> MyApp.Repo.transact()
@spec request_email_change(Sigra.Config.t(), struct(), String.t(), keyword()) :: {:ok, struct(), String.t()} | {:error, term()}
Request an email change for the user.
Generates a verification token for the new email address and returns
it for delivery. The email is not changed until confirmed via
confirm_email_change/3.
@spec request_magic_link(module(), String.t(), keyword()) :: {:ok, {String.t(), String.t()}} | {:ok, :sent} | {:error, :rate_limited}
Requests a magic link for the given email.
If the user exists and is not rate-limited, generates a token and returns
{:ok, {raw_token, url}}. If the user does not exist, returns {:ok, :sent}
to prevent email enumeration. If rate-limited, returns {:error, :rate_limited}.
Options
:user_schema- Required. The Ecto schema module for users.:user_token_schema- Required. The Ecto schema module for user tokens (e.g.,MyApp.Accounts.UserToken).:url_fun- Required. Function(token -> url_string).:rate_limiter- Module implementingSigra.RateLimiter. Default:nil(no rate limiting).:max_requests- Max magic link requests per window. Default:3.:window_ms- Rate limit window in milliseconds. Default:900_000(15 min).
@spec request_password_reset(module(), String.t(), keyword()) :: {:ok, {String.t(), String.t()}} | {:ok, :sent} | {:error, :rate_limited}
Requests a password reset for the given email.
Enumeration-safe: always returns {:ok, :sent} for non-existent emails
with a dummy hash operation to match timing (per D-38).
Options
:user_schema- Required. The Ecto schema module for users.:user_token_schema- Required. The Ecto schema module for user tokens (e.g.,MyApp.Accounts.UserToken).:secret_key_base- Required. The host app's secret key base.:url_fun- Required. Function(token -> url_string).:rate_limiter- Module implementingSigra.RateLimiter. Default:nil.:max_requests- Max reset requests per window. Default:3.:window_ms- Rate limit window in milliseconds. Default:900_000(15 min).
@spec reset_password(module(), String.t(), map(), keyword()) :: {:ok, struct()} | {:error, :token_expired | :token_invalid | Ecto.Changeset.t()}
Resets a user's password using a valid reset token.
Verifies HMAC signature, looks up token in DB, changes password, and invalidates ALL tokens (including sessions) in a single transaction. Per D-29: auto-login after reset (caller creates new session).
Options
:secret_key_base- Required. The host app's secret key base.:user_token_schema- Required. The Ecto schema module for user tokens.:user_schema- Required. The Ecto schema module for users.:changeset_fn- Required. Function(user, attrs -> Ecto.Changeset.t()).:reset_ttl- Token TTL in seconds. Default:3600(1 hour).
@spec revoke_all_api_tokens( Sigra.Config.t(), struct() ) :: {:ok, non_neg_integer()}
Revokes all active API tokens for a user.
@spec revoke_api_token(Sigra.Config.t(), term()) :: {:ok, struct()} | {:error, :not_found}
Revokes an API token by ID.
@spec revoke_jwt_refresh(Sigra.Config.t(), String.t()) :: :ok | {:error, :invalid_token}
Revokes a JWT refresh token.
@spec revoke_session(Sigra.Config.t(), binary(), keyword()) :: :ok
Revoke a specific session by hashed_token.
Delegates to delete_session/3.
@spec schedule_deletion(Sigra.Config.t(), struct(), keyword()) :: {:ok, struct(), DateTime.t()} | {:error, term()}
Schedule account deletion with grace period.
Immediately deactivates the account (revokes sessions/tokens) and schedules final deletion after the configured grace period.
@spec set_password(Sigra.Config.t(), struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}
Set password for OAuth-only user.
Allows users who registered via OAuth (no password set) to add a password to their account. Requires sudo mode.
@spec unlink_provider(Sigra.Config.t() | map(), map(), atom() | String.t(), keyword()) :: {:ok, :unlinked} | {:error, :last_provider | :not_found}
Unlinks an OAuth provider from a user.
Blocks if last auth method and no password set (D-03).
Sends notification email. Returns {:ok, :unlinked} or {:error, reason}.
Returns true if the given string looks like a valid email address.
Uses a deliberately loose regex — the goal is to catch obvious typos
(alice@, @example.com, not-an-email) before hitting the database,
not to enforce RFC 5322 compliance. Full validation happens when you
actually attempt to deliver the message.
Accepts only binaries. Non-binary input (including nil) returns
false rather than raising, so the helper composes inside pipelines.
Examples
iex> Sigra.Auth.valid_email?("alice@example.com")
true
iex> Sigra.Auth.valid_email?("bob@example.co.uk")
true
iex> Sigra.Auth.valid_email?("not-an-email")
false
iex> Sigra.Auth.valid_email?("alice@")
false
iex> Sigra.Auth.valid_email?("@example.com")
false
iex> Sigra.Auth.valid_email?(nil)
false
@spec verify_confirmation_code(module(), String.t(), keyword()) :: {:ok, struct()} | {:error, :invalid_code | :rate_limited | :already_confirmed}
Confirms a user by 6-digit code.
SHA-256 hashes the submitted code, looks up in DB with context "confirm_code". Rate-limited to 5 attempts per user per 15 minutes.
Options
:user_id- Required. The user ID to confirm.:user_token_schema- Required. The Ecto schema module for user tokens.:user_schema- Required. The Ecto schema module for users.:secret_key_base- Required. The host app's secret key base.:rate_limiter- Module implementingSigra.RateLimiter. Default:nil.:max_code_attempts- Max code verification attempts per window. Default:5.:code_window_ms- Rate limit window in milliseconds. Default:900_000(15 min).
@spec verify_magic_link(module(), String.t(), keyword()) :: {:ok, struct()} | {:error, :invalid | :expired}
Verifies a magic link token. Confirms user if unconfirmed.
The token is single-use: it is deleted from the database after successful
verification. If the user has not confirmed their email, confirmed_at
is set to the current time.
Options
:user_schema- Required. The Ecto schema module for users.:user_token_schema- Required. The Ecto schema module for user tokens.:magic_link_ttl- Token TTL in seconds. Default:600(10 minutes).