An Ash extension for OpenFeed — Australian Consumer Data Right banking and energy data.

Built on the openfeed package, which handles the FAPI 2.0 Security Profile (PAR, PKCE, DPoP) and key management. This package adds the Ash half: grant persistence, token refresh, and an installer that generates the consent flow.

Requirements

  • Elixir 1.17 or later
  • OTP 27 or later — this is a hard floor, inherited from oidcc, whose Erlang source does not compile on OTP 26. Both packages check for it and fail with a clear message rather than letting you hit an unexplained dependency compile error.

Tested in CI against Elixir 1.17, 1.18, 1.19 and 1.20.

Installation

mix igniter.install ash_openfeed

That generates an Ash domain, a grant resource, a signing-key resource, a consent controller and its routes, then tells you the remaining steps.

Prefer to wire it up yourself? Add the dep and read on.

def deps do
  [{:ash_openfeed, "~> 0.1"}]
end

The extension goes on your resource

defmodule MyApp.OpenFeed.Grant do
  use Ash.Resource,
    domain: MyApp.OpenFeed,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOpenFeed.Grant]

  openfeed do
    otp_app :my_app
    key_store {AshOpenFeed.KeyStore.Ash, resource: MyApp.OpenFeed.Key}
    grant_management? true
    extra_accept [:user_id]
  end

  postgres do
    table "openfeed_grants"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    timestamps()
  end

  relationships do
    belongs_to :user, MyApp.Accounts.User
  end
end

The extension injects the token attributes, a unique identity on grant_id, and the lifecycle actions. It does not ship its own resource, so that you can add your own relationships, policies, multitenancy and encryption. Everything is added with Ash's add_new_* builders — anything you declare yourself wins.

Credentials go in runtime.exs, where secrets belong:

config :my_app, :openfeed,
  client_id: System.fetch_env!("OPENFEED_CLIENT_ID"),
  redirect_uri: "https://my.app/openfeed/callback"

If credentials vary per tenant, implement AshOpenFeed.ConfigProvider and set config_provider instead.

Using it

# 1. Start a consent flow. Keep `flow` in the session.
flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.authorize_url(MyApp.OpenFeed.Grant, flow)

# 2. At the callback, having checked `state` matches.
{:ok, grant} =
  AshOpenFeed.complete_authorization(MyApp.OpenFeed.Grant, code, flow,
    attributes: %{user_id: current_user.id}
  )

# 3. Read data. The token is refreshed first if it is near expiry.
{:ok, accounts} =
  AshOpenFeed.with_token(grant, &OpenFeed.Sharing.banking_accounts(&1, &2))

# 4. Let the consumer disconnect.
{:ok, grant} = AshOpenFeed.revoke(grant)

with_token/3 also reconciles local state from what the API said: a revoked grant gets marked revoked, exhausted credit gets marked suspended. The next caller does not have to repeat a request that is guaranteed to fail.

Collecting data into your own resources

Step 3 above gets you one endpoint. A real sync walks the tree and stores it. This library deliberately does not model CDR data for you — how much history to keep and how to shape it is an application decision — so the loop is yours:

def sync(grant) do
  AshOpenFeed.with_token(grant, fn config, tokens ->
    {:ok, accounts} = OpenFeed.Sharing.banking_accounts(config, tokens)

    for account <- accounts do
      {:ok, stored} =
        MyApp.Banking.upsert_account(%{
          grant_id: grant.id,
          account_id: account["accountId"],
          display_name: account["displayName"],
          provider_name: account["providerName"]
        })

      {:ok, transactions} =
        OpenFeed.Sharing.banking_transactions(config, tokens, account["accountId"],
          oldest_date: oldest_date(grant)
        )

      for tx <- transactions do
        MyApp.Banking.upsert_transaction(%{
          account_id: stored.id,
          transaction_id: tx["transactionId"],
          # Banking amounts are ISO 20022 strings, energy amounts are JSON
          # numbers. OpenFeed.Amount takes either.
          amount: OpenFeed.Amount.to_decimal(tx["amount"]),
          description: tx["description"],
          transaction_date: Date.from_iso8601!(tx["transactionDate"])
        })
      end
    end
  end)
end

# Re-read a week before the last sync, so late-posted transactions land. Upserting
# on the provider's transactionId makes the overlap free of duplicates.
defp oldest_date(%{last_synced_at: nil}), do: Date.add(Date.utc_today(), -365)
defp oldest_date(%{last_synced_at: at}), do: at |> DateTime.to_date() |> Date.add(-7)

Two things worth doing properly rather than by hand — see the Collecting data guide:

For a collection that could be large — a year of transactions ran to nearly 4,000 records on one real account — stream instead of materialising it:

OpenFeed.Client.stream(config, tokens, "/v1/banking/accounts/#{id}/transactions")
|> Stream.chunk_every(500)
|> Enum.each(&bulk_insert!/1)

Collect errors, do not abandon the run

One unavailable balance should not cost you a year of transactions. Gather failures and decide once at the end, branching on OpenFeed.Error's :kind:

cond do
  Enum.any?(errors, &OpenFeed.Error.grant_revoked?/1) ->
    # Terminal. The consumer withdrew consent.
    Ash.update!(grant, %{}, action: :mark_revoked)

  Enum.any?(errors, &OpenFeed.Error.credit_exhausted?/1) ->
    # Your credit, not their consent. The grant is still good.
    Ash.update!(grant, %{}, action: :mark_metering_suspended)

  true ->
    Ash.update!(grant, %{}, action: :touch_synced)
end

Note that a 403 subject_mismatch is not a revocation, despite sharing the status code. grant_revoked?/1 gets that right; matching on 403 does not.

Scheduling it

ash_oban is the natural fit. OpenFeed refreshes its own mirror roughly every 4 hours for banking and 6 for energy, so that is the practical ceiling on useful polling:

config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron, crontab: [{"0 */4 * * *", MyApp.OpenFeed.SyncAllWorker}]}
  ]

Polling more often is not billed differently — OpenFeed charges per grant per month, not per request — but it cannot surface data OpenFeed has not fetched yet. See Cost and cadence.

Because refresh tokens are not rotated, concurrent refreshes are wasteful rather than destructive. If you fan out across many grants, give the worker a uniqueness constraint rather than a database lock.

A worked example

examples/openfeed_demo is a Phoenix + Ash app doing all of this against production OpenFeed; lib/openfeed_demo/open_feed/sync.ex is the file to read.

Grant management

A grant is the consumer's consent, and it is not a one-shot thing. OpenFeed supports the CDR Grant Management surface, so consent can be amended, queried and revoked — and it can disappear from under you.

A consumer has at most one active grant per app, so a fresh authorize_url/3 reuses the one they already have rather than creating a second.

Letting a consumer change what they share

flow = AshOpenFeed.new_flow()
{:ok, url} = AshOpenFeed.amend_url(grant, flow)
redirect(conn, external: url)

OpenFeed keeps the same grant_id and bumps its revision, so handle the callback exactly as you handle a first connection — the upsert lands on the same row.

Noticing revocations

There are no webhooks. Two things will tell you a consumer disconnected:

A failing call. A data call returns 403 → :grant_revoked. More often, a token refresh is rejected: OpenFeed sweeps the refresh tokens bound to a revoked grant, so a sync usually finds out while refreshing. AshOpenFeed marks the grant revoked when it sees either, so a disconnected consumer stops costing you a doomed refresh on every run.

Polling. The proactive route:

{:ok, summary} = AshOpenFeed.reconcile_grants(MyApp.OpenFeed.Grant)
#=> %{checked: 12, revoked: 1, updated: 2, unchanged: 9, unknown: 0}

This diffs your grants against OpenFeed's app-level index. A grant missing from it was revoked upstream; a higher revision means consent was amended, so the full state is fetched and applied. It costs nothing — app-level calls are not metered.

Usefully, phase one needs only the app-level scope from a client-credentials token, so revocation detection works even without the per-grant management scopes. Without them an amendment is still noticed and its revision recorded — you just cannot read the new authorised id lists.

revision is the signal worth acting on

It increments only when the consumer changes which accounts they share — not on a status or metering change. So a bump means exactly one thing, and it is the cheap trigger for re-reading accounts and pruning what has gone:

if reloaded.revision > grant.revision do
  # Consent scope changed — re-read accounts and prune locally.
end

The account id lists are opaque

refresh_status/2 stores banking_account_ids and energy_account_ids, but you cannot match them against your stored accounts — they are account_identity ids from a different identifier space to the accountId values the data endpoints return, with no mapping available to a third-party client.

Use them for change detection and audit. To work out what to stop syncing, do it the straightforward way: after a revision bump, re-read the account lists and prune whatever is no longer returned.

See the Grant management guide for the full picture.

Two things that are easy to get wrong

Not every 403 means the consumer withdrew consent. OpenFeed returns 403 for both disclosure_grant_required (consent gone) and subject_mismatch (a bug on your side). Treating them alike marks healthy grants dead. OpenFeed.Error classifies them as :grant_revoked and :subject_mismatch, and only the first is terminal.

Keys must not be generated per node. A key generated lazily on first use gives each node in a cluster a different key, so DPoP proofs get signed with keys absent from your registered JWKS — an intermittent, load-balancer-dependent failure. AshOpenFeed.KeyStore.Ash holds one key in the database, shared by every node. Key creation is always an explicit, operator-driven step.

A naming clash to watch for

The installer names your domain MyApp.OpenFeed by default, which shadows this library's top-level OpenFeed module if you alias it bare:

alias MyApp.OpenFeed            # now OpenFeed.Sharing means MyApp.OpenFeed.Sharing

Alias it explicitly instead:

alias MyApp.OpenFeed, as: Grants
alias OpenFeed.{Error, Sharing}

Or pass --domain MyApp.Banking to the installer to avoid the collision entirely.

Token types are not guessable

AshOpenFeed.with_token/3 hands your callback an OpenFeed.Tokens struct, not a bare string, and you should pass that struct straight to OpenFeed.Sharing. It carries the token_type OpenFeed actually issued, which is the only reliable source of the authorization scheme.

This matters more than it sounds: OpenFeed issues client-credentials tokens as Bearer even to a private_key_jwt client, so inferring DPoP from the client profile sends Authorization: DPoP <bearer token> and earns a bare 401 with no error code to explain it.

Encrypt your tokens

The generated resource stores tokens as plaintext columns. A refresh token here is unusually valuable: OpenFeed does not rotate them, and Recommended-profile refresh tokens live as long as the grant does, so a leaked row is durable access to somebody's banking data rather than a short window.

use Ash.Resource, extensions: [AshOpenFeed.Grant, AshCloak.Resource]

cloak do
  vault MyApp.Vault
  attributes [:access_token, :refresh_token]
end

A compile-time verifier warns if it cannot see encryption configured. It warns rather than errors — this is a deployment judgement — but it should be a conscious one. Silence it with config :ash_openfeed, warn_unencrypted?: false.

Note also that Ash's sensitive? keeps tokens out of inspect/1 and error messages, but does not reach a data layer's own debug logging.

Refresh concurrency

AshOpenFeed.access_token/2 refreshes without taking a lock, deliberately. OpenFeed does not rotate refresh tokens — FAPI 2.0 permits sender-constraint (DPoP) as the alternative — so a refresh token stays valid after use and two concurrent refreshes both succeed. Concurrent refresh is wasteful, not destructive. If you want to avoid the waste when fanning out across many grants, give your worker a uniqueness constraint rather than reaching for a database lock.

What is not here

A CDR data model. There are no Banking or Energy resources, and no sync engine. How much history to keep, how to shape it, and how often to refresh are application decisions, and shipping opinions about them would make this a far larger and more brittle package.

What you get instead is the hard part — FAPI 2.0, key management, grant and token lifecycle — plus a client that returns decoded maps, helpers for the two payload shapes that are easy to get wrong, and a worked example of the loop to copy from. See Collecting data into your own resources above.

License

Apache-2.0