Sigra.Auth (Sigra v1.20.0)

Copy Markdown View Source

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 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

authenticate(repo_or_config, params, opts \\ [])

(since 0.2.0)
@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 check confirmed_at. Default: false.

Telemetry

  • [:sigra, :auth, :login, :start | :stop | :exception] span

  • [:sigra, :auth, :hash_upgraded] event when hash is upgraded

cancel_deletion(config, user, opts \\ [])

(since 0.8.0)
@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).

cancel_email_change(config, user, opts \\ [])

(since 0.8.0)
@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.

change_password(config, user, current_password, attrs, opts \\ [])

(since 0.8.0)
@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.

complete_mfa_verification(config, user, old_session, opts \\ [])

(since 0.6.0)
@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 - If true, upgrades to :remember_me session. Default: false.
  • :trust_browser - If true, 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

confirm_email_change(config, encoded_token, opts \\ [])

(since 0.8.0)
@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).

confirm_sudo(config, hashed_token, opts \\ [])

(since 0.4.0)
@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.

confirm_user(repo, encoded_token, opts \\ [])

(since 0.3.0)
@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).

create_api_token(config, user, attrs)

(since 0.7.0)
@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{} struct
  • user - The user struct (must have an :id field)
  • attrs - A map with :name, :scopes, and optional :expires_at

create_session(config, user, metadata, opts \\ [])

(since 0.4.0)
@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 to Sigra.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).

delete_all_sessions(config, user_id, opts \\ [])

(since 0.4.0)
@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.

delete_session(config, hashed_token, opts \\ [])

(since 0.4.0)
@spec delete_session(Sigra.Config.t(), binary(), keyword()) :: :ok

Delete a specific session by its hashed token.

Emits a [:sigra, :session, :delete] telemetry span.

execute_deletion(config, user, opts \\ [])

(since 0.8.0)
@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.

generate_confirmation_token(repo, user, opts \\ [])

(since 0.3.0)
@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.

generate_jwt_tokens(config, user, scopes)

(since 0.7.0)
@spec generate_jwt_tokens(Sigra.Config.t(), struct(), [String.t()]) ::
  {:ok, map()} | {:error, term()}

Generates JWT access + refresh tokens for a user.

list_api_scopes(config)

(since 0.7.0)
@spec list_api_scopes(Sigra.Config.t()) :: [String.t()]

Returns all registered API token scopes.

list_api_tokens(config, user_id, opts \\ [])

(since 0.7.0)
@spec list_api_tokens(Sigra.Config.t(), term(), keyword()) ::
  {[struct()], String.t() | nil}

Lists active API tokens for a user with cursor pagination.

list_sessions(config, user_id, opts \\ [])

(since 0.4.0)
@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).

login_oauth(config, provider, user_info, token)

(since 0.5.0)
@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.

normalize_email(email)

(since 0.10.0)
@spec normalize_email(String.t() | nil) :: String.t() | nil

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

refresh_jwt(config, raw_refresh_token)

(since 0.7.0)
@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).

register(repo, attrs, opts \\ [])

(since 0.2.0)
@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}.

register_oauth(config, provider, user_info, token)

(since 0.5.0)
@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).

register_user_multi(attrs, opts)

(since 0.4.0)
@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 from attrs.

Example

Ecto.Multi.new()
|> Ecto.Multi.append(
     Sigra.Auth.register_user_multi(attrs, changeset_fn: &User.registration_changeset/1)
   )
|> MyApp.Repo.transact()

request_email_change(config, user, new_email, opts \\ [])

(since 0.8.0)
@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.

request_magic_link(repo, email, opts \\ [])

(since 0.2.0)
@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 implementing Sigra.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).

request_password_reset(repo, email, opts \\ [])

(since 0.3.0)
@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 implementing Sigra.RateLimiter. Default: nil.
  • :max_requests - Max reset requests per window. Default: 3.
  • :window_ms - Rate limit window in milliseconds. Default: 900_000 (15 min).

reset_password(repo, encoded_token, password_attrs, opts \\ [])

(since 0.3.0)
@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).

revoke_all_api_tokens(config, user)

(since 0.7.0)
@spec revoke_all_api_tokens(
  Sigra.Config.t(),
  struct()
) :: {:ok, non_neg_integer()}

Revokes all active API tokens for a user.

revoke_api_token(config, token_id)

(since 0.7.0)
@spec revoke_api_token(Sigra.Config.t(), term()) ::
  {:ok, struct()} | {:error, :not_found}

Revokes an API token by ID.

revoke_jwt_refresh(config, raw_refresh_token)

(since 0.7.0)
@spec revoke_jwt_refresh(Sigra.Config.t(), String.t()) ::
  :ok | {:error, :invalid_token}

Revokes a JWT refresh token.

revoke_session(config, hashed_token, opts \\ [])

(since 0.4.0)
@spec revoke_session(Sigra.Config.t(), binary(), keyword()) :: :ok

Revoke a specific session by hashed_token.

Delegates to delete_session/3.

schedule_deletion(config, user, opts \\ [])

(since 0.8.0)
@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.

set_password(config, user, attrs, opts \\ [])

(since 0.8.0)
@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.

valid_email?(email)

(since 0.10.0)
@spec valid_email?(term()) :: boolean()

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

verify_confirmation_code(repo, code, opts \\ [])

(since 0.3.0)
@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 implementing Sigra.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).

verify_magic_link(repo, raw_token, opts \\ [])

(since 0.2.0)
@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).