Sigra.APIToken (Sigra v1.20.0)

Copy Markdown View Source

Core API token operations: creation, verification, revocation, and scope checks.

API tokens (also called personal access tokens or secret keys) allow users to authenticate API requests without session cookies. Tokens use a prefix format (e.g., my_app_sk_...) for easy identification and are stored as SHA-256 hashes in the database.

Token Lifecycle

  1. Create -- create/3 generates a prefixed token, validates scopes, and stores the SHA-256 hash. The raw token is returned once and never stored.

  2. Verify -- verify/2 hashes the submitted token and looks up the hash. Revoked and expired tokens are rejected. Successful verification does not write a durable audit row (D-27); maybe_update_last_used/2 bumps last_used_at asynchronously via Task.start/1 so the hot path stays low-latency while telemetry covers observability. When :audit_schema is configured, api.token_verify.failure rows are written inside Repo.transaction/1 via Ecto.Multi + Audit.log_multi_safe/3 (audit-only transaction; success remains unaudited per D-27).

  3. Revoke -- revoke/2 soft-deletes a token by setting revoked_at. revoke_all/2 revokes all active tokens for a user. With :audit_schema configured, both operations append api.token_revoke / api.token_revoke_all on the same Ecto.Multi as the DB write (AUD-07).

  4. JWT refresh auditing -- audit_jwt_refresh/2 and audit_jwt_refresh_reuse/2 emit api.jwt_refresh / api.jwt_refresh_reuse rows when :audit_schema is set, using Repo.transaction/1 + Ecto.Multi + Audit.log_multi_safe/3 (audit-only transaction; AUD-18). When :audit_schema is set, Sigra.JWT.refresh/3 also performs persistence + audit co-fate in one transaction — do not call audit_jwt_refresh/2 afterward or you risk double-audit rows.

Scope System

Tokens carry a list of scopes in resource:action format (e.g., "profile:read"). The can?/2 function checks whether a token's scopes satisfy requirements. The special "*" wildcard scope grants access to all resources.

Security

  • Raw tokens are never stored; only SHA-256 hashes are persisted
  • Token prefix is validated to prevent JWT collision (eyJ prefix blocked)
  • All operations emit telemetry events for observability

Summary

Functions

Appends a JWT audit insert step to the given Ecto.Multi.

Emit api.jwt_refresh audit row (called from Sigra.JWT refresh flow).

Emit api.jwt_refresh_reuse audit row (detected refresh-token reuse).

Checks whether a token or scope struct has the required scopes.

Creates a new API token for the given user.

Decodes an opaque cursor string into {inserted_at, id}.

Encodes an inserted_at timestamp and ID into an opaque cursor string.

Lists active (non-revoked, non-expired) API tokens for a user with cursor pagination.

Returns all registered scopes (built-in + custom).

Revokes a single API token by ID.

Revokes all active API tokens for a user.

Verifies a raw API token string.

Functions

append_api_token_jwt_audit_to_multi(multi, action, opts)

(since 0.9.1)

Appends a JWT audit insert step to the given Ecto.Multi.

Delegates to Sigra.Audit.log_multi_safe/3 (no-op when :audit_schema is nil).

For internal composition from Sigra.JWT.refresh/3 when :audit_schema is set. Host applications should not compose arbitrary multis unless they own the outer Repo.transaction/1.

audit_jwt_refresh(config, user_id)

(since 0.9.0)
@spec audit_jwt_refresh(Sigra.Config.t(), term()) :: :ok

Emit api.jwt_refresh audit row (called from Sigra.JWT refresh flow).

When :audit_schema is configured, the row is written inside Repo.transaction/1 via audit-only Ecto.Multi + Audit.log_multi_safe/3 (same durability posture as verify-failure auditing).

Returns :ok even when auditing is disabled or when the audit insert is rejected or rolled back after the host transaction has already committed elsewhere. :ok does not prove the audit row exists; monitor [:sigra, :audit, :log_safe_error] for reason: :invalid_changeset or :constraint_violation.

This is exposed so the JWT refresh implementation (potentially a separate module) can write a consistent audit row through this module's helpers.

audit_jwt_refresh_reuse(config, user_id)

(since 0.9.0)
@spec audit_jwt_refresh_reuse(Sigra.Config.t(), term()) :: :ok

Emit api.jwt_refresh_reuse audit row (detected refresh-token reuse).

When :audit_schema is configured, uses the same transactional Multi + log_multi_safe path as audit_jwt_refresh/2.

Returns :ok regardless of whether a durable audit row was persisted; see audit_jwt_refresh/2 and [:sigra, :audit, :log_safe_error] for operational honesty.

can?(token_or_scope, required_scopes, opts \\ [])

(since 0.7.0)
@spec can?(map(), [String.t()], keyword()) :: boolean()

Checks whether a token or scope struct has the required scopes.

Accepts either a map with :scopes (token struct) or :token_scopes (scope struct from conn.assigns).

Options

  • :match - :all (default) requires all scopes, :any requires at least one

Examples

Sigra.APIToken.can?(token, ["profile:read"])
#=> true

Sigra.APIToken.can?(token, ["admin:write"], match: :any)
#=> false

create(config, user, attrs)

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

Creates a new API token for the given user.

Returns {:ok, raw_key, token_record} on success. The raw_key includes the configured prefix and should be shown to the user exactly once.

Parameters

  • config - A %Sigra.Config{} struct
  • user - The user struct (must have an :id field)
  • attrs - A map with:
    • :name (required) - Human-readable token name, max 255 chars
    • :scopes (required) - List of scope strings
    • :expires_at (optional) - Expiration datetime

Examples

{:ok, raw_key, token} = Sigra.APIToken.create(config, user, %{
  name: "CI Deploy Key",
  scopes: ["profile:read", "api_tokens:read"]
})

decode_cursor(cursor)

(since 0.7.0)
@spec decode_cursor(String.t()) :: {DateTime.t(), integer()}

Decodes an opaque cursor string into {inserted_at, id}.

encode_cursor(inserted_at, id)

(since 0.7.0)
@spec encode_cursor(DateTime.t(), term()) :: String.t()

Encodes an inserted_at timestamp and ID into an opaque cursor string.

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

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

Lists active (non-revoked, non-expired) API tokens for a user with cursor pagination.

Returns {tokens, next_cursor} where next_cursor is nil on the last page.

Options

  • :limit - Page size (default from config, max from config)
  • :cursor - Opaque cursor string from a previous call

Examples

{tokens, cursor} = Sigra.APIToken.list_active(config, user_id)
{more_tokens, nil} = Sigra.APIToken.list_active(config, user_id, cursor: cursor)

list_scopes(config)

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

Returns all registered scopes (built-in + custom).

Delegates to Sigra.APIToken.ScopeRegistry.all_scopes/1.

Examples

scopes = Sigra.APIToken.list_scopes(config)
#=> ["profile:read", "profile:write", ...]

revoke(config, token_id)

(since 0.7.0)
@spec revoke(Sigra.Config.t(), term()) ::
  {:ok, map()} | {:error, :not_found} | {:error, Ecto.Changeset.t()}

Revokes a single API token by ID.

Sets revoked_at to the current UTC time. Returns {:ok, token} on success or {:error, :not_found} if the token does not exist.

Examples

{:ok, revoked_token} = Sigra.APIToken.revoke(config, token_id)

revoke_all(config, user)

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

Revokes all active API tokens for a user.

Sets revoked_at on all tokens where revoked_at IS NULL for the given user. Returns {:ok, count} with the number of tokens revoked.

Examples

{:ok, 3} = Sigra.APIToken.revoke_all(config, user)

verify(config, raw_token)

(since 0.7.0)
@spec verify(Sigra.Config.t(), String.t()) ::
  {:ok, map()} | {:error, :invalid_token | :token_revoked | :token_expired}

Verifies a raw API token string.

Hashes the token and looks up the hash in the database. Returns {:ok, token} for valid active tokens, or an error tuple.

Error Returns

  • {:error, :invalid_token} - Token not found
  • {:error, :token_revoked} - Token has been revoked
  • {:error, :token_expired} - Token has expired

Examples

case Sigra.APIToken.verify(config, raw_token) do
  {:ok, token} -> # authenticated
  {:error, reason} -> # rejected
end