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"}]
endThe 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
endThe 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:
- Money has two wire formats. Use
OpenFeed.Amount.to_decimal/1. - Energy usage reads carry a discriminator. Use
OpenFeed.Energy.net_usage/1, and recordOpenFeed.Energy.has_reads?/1so "no data" stays distinguishable from "zero usage".
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)
endNote 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.
endThe 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.SharingAlias 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]
endA 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