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
Create --
create/3generates a prefixed token, validates scopes, and stores the SHA-256 hash. The raw token is returned once and never stored.Verify --
verify/2hashes 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/2bumpslast_used_atasynchronously viaTask.start/1so the hot path stays low-latency while telemetry covers observability. When:audit_schemais configured,api.token_verify.failurerows are written insideRepo.transaction/1viaEcto.Multi+Audit.log_multi_safe/3(audit-only transaction; success remains unaudited per D-27).Revoke --
revoke/2soft-deletes a token by settingrevoked_at.revoke_all/2revokes all active tokens for a user. With:audit_schemaconfigured, both operations appendapi.token_revoke/api.token_revoke_allon the sameEcto.Multias the DB write (AUD-07).JWT refresh auditing --
audit_jwt_refresh/2andaudit_jwt_refresh_reuse/2emitapi.jwt_refresh/api.jwt_refresh_reuserows when:audit_schemais set, usingRepo.transaction/1+Ecto.Multi+Audit.log_multi_safe/3(audit-only transaction; AUD-18). When:audit_schemais set,Sigra.JWT.refresh/3also performs persistence + audit co-fate in one transaction — do not callaudit_jwt_refresh/2afterward 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 (
eyJprefix 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
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.
@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.
@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.
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,:anyrequires at least one
Examples
Sigra.APIToken.can?(token, ["profile:read"])
#=> true
Sigra.APIToken.can?(token, ["admin:write"], match: :any)
#=> false
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{}structuser- The user struct (must have an:idfield)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"]
})
@spec decode_cursor(String.t()) :: {DateTime.t(), integer()}
Decodes an opaque cursor string into {inserted_at, id}.
@spec encode_cursor(DateTime.t(), term()) :: String.t()
Encodes an inserted_at timestamp and ID into an opaque cursor string.
@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)
@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", ...]
@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)
@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)
@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