ExShopifyApp.AccessToken.Token (ex_shopify_app v1.0.0)

The canonical persisted representation of a Shopify offline access token.

This is an Ecto schema keyed by shopify_domain: there is exactly one offline token chain per shop/app installation. It captures the full response from an expiring token exchange or refresh, the absolute timestamps at which the access token and refresh token expire, and operational refresh metadata.

Lifetime (non-expiring) tokens leave :expires_at and :refresh_token_expires_at as nil; such tokens are never considered expired or stale.

The struct redacts :access_token and :refresh_token from inspect/2 output so token values never leak into logs.

Docs: https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens

Summary

Types

t()

An offline access token row with its expiry and refresh metadata.

Functions

Returns the fields accepted by changeset/2.

Changeset for persisting a token (initial exchange / reauthorization upsert).

Returns true when the access token has hard-expired (within skew seconds).

Builds a token struct from a decoded token-exchange/refresh response body.

Normalizes a shop domain the same way ExShopifyApp.AccessToken.client/1 does: strips a leading https:// so the stored key matches the host used for requests.

Builds the update changeset applied after a successful refresh.

Returns true when the refresh token has expired. Once this is true the merchant must re-launch the app to obtain a new token; refreshing is no longer possible.

Returns the fields replaced during an upsert conflict.

Returns true when the access token is inside the proactive soft refresh window (still valid, but nearing expiry) — a stale-while-revalidate refresh should be triggered.

Types

t()

@type t() :: %ExShopifyApp.AccessToken.Token{
  __meta__: term(),
  access_token: String.t() | nil,
  expires_at: DateTime.t() | nil,
  expires_in: non_neg_integer() | nil,
  inserted_at: term(),
  last_refresh_error: String.t() | nil,
  last_refreshed_at: DateTime.t() | nil,
  refresh_generation: non_neg_integer(),
  refresh_token: String.t() | nil,
  refresh_token_expires_at: DateTime.t() | nil,
  refresh_token_expires_in: non_neg_integer() | nil,
  scope: String.t() | nil,
  shopify_domain: String.t() | nil,
  updated_at: term()
}

An offline access token row with its expiry and refresh metadata.

Functions

castable()

@spec castable() :: [atom()]

Returns the fields accepted by changeset/2.

changeset(token, attrs)

@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()

Changeset for persisting a token (initial exchange / reauthorization upsert).

Requires shopify_domain and access_token. For expiring tokens (any expiry field present) it additionally requires refresh_token, expires_at, and refresh_token_expires_at. Integer durations must be non-negative. The shopify_domain is normalized consistently with the HTTP client's host handling.

expired?(token, now \\ DateTime.utc_now(), skew \\ 60)

@spec expired?(t(), DateTime.t(), non_neg_integer()) :: boolean()

Returns true when the access token has hard-expired (within skew seconds).

A blocking refresh is required before the token can be used. Lifetime tokens (expires_at == nil) are never expired.

from_response(body, shopify_domain \\ nil)

@spec from_response(map(), String.t() | nil) :: t()

Builds a token struct from a decoded token-exchange/refresh response body.

Computes absolute expiry timestamps from the expires_in/refresh_token_expires_in durations relative to the current time. Accepts string-keyed maps (as decoded from JSON). The result is a transient struct — persist it through a ExShopifyApp.AccessToken.Store before relying on it.

normalize_domain(domain)

@spec normalize_domain(String.t() | nil) :: String.t() | nil

Normalizes a shop domain the same way ExShopifyApp.AccessToken.client/1 does: strips a leading https:// so the stored key matches the host used for requests.

prepare_refresh_changes(refreshed, existing)

@spec prepare_refresh_changes(t(), t()) :: Ecto.Changeset.t()

Builds the update changeset applied after a successful refresh.

Copies the freshly issued values from refreshed onto the locked existing row, stamps last_refreshed_at, clears last_refresh_error, and uses Ecto.Changeset.optimistic_lock/3 to increment refresh_generation (and guard against a concurrent writer). The shopify_domain is preserved.

refresh_token_expired?(token, now \\ DateTime.utc_now())

@spec refresh_token_expired?(t(), DateTime.t()) :: boolean()

Returns true when the refresh token has expired. Once this is true the merchant must re-launch the app to obtain a new token; refreshing is no longer possible.

replaceable()

@spec replaceable() :: [atom()]

Returns the fields replaced during an upsert conflict.

stale?(token, now \\ DateTime.utc_now(), opts \\ [])

@spec stale?(t(), DateTime.t(), keyword()) :: boolean()

Returns true when the access token is inside the proactive soft refresh window (still valid, but nearing expiry) — a stale-while-revalidate refresh should be triggered.

The window opens once remaining lifetime drops below :fraction of the original expires_in (default 0.25), plus a deterministic per-shop jitter of up to :jitter seconds (default 30) so co-issued tokens stagger their refreshes. Lifetime tokens are never stale.

Options

  • :fraction - soft-window fraction of total lifetime (default 0.25)
  • :jitter - max jitter in seconds, spread per shop (default 30)